mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-18 17:18:11 +00:00
refactor(tv): extract shared components to reduce code duplication
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
|
||||
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||
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 { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
@@ -19,46 +20,34 @@ const TVSettingsRow: React.FC<{
|
||||
showChevron?: boolean;
|
||||
disabled?: boolean;
|
||||
}> = ({ label, value, onPress, isFirst, showChevron = true, disabled }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.02);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirst && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
@@ -88,46 +77,34 @@ const TVSettingsToggle: React.FC<{
|
||||
isFirst?: boolean;
|
||||
disabled?: boolean;
|
||||
}> = ({ label, value, onToggle, isFirst, disabled }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onToggle(!value)}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.02);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirst && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
|
||||
<View
|
||||
@@ -173,21 +150,9 @@ const TVSettingsStepper: React.FC<{
|
||||
isFirst,
|
||||
disabled,
|
||||
}) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [buttonFocused, setButtonFocused] = useState<"minus" | "plus" | null>(
|
||||
null,
|
||||
);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
const minusScale = useRef(new Animated.Value(1)).current;
|
||||
const plusScale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (ref: Animated.Value, v: number) =>
|
||||
Animated.timing(ref, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
|
||||
const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
|
||||
|
||||
const displayValue = formatValue ? formatValue(value) : String(value);
|
||||
|
||||
@@ -195,7 +160,7 @@ const TVSettingsStepper: React.FC<{
|
||||
<View
|
||||
style={{
|
||||
backgroundColor:
|
||||
focused || buttonFocused
|
||||
labelAnim.focused || minusAnim.focused || plusAnim.focused
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 12,
|
||||
@@ -208,47 +173,36 @@ const TVSettingsStepper: React.FC<{
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(scale, 1.02);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(scale, 1);
|
||||
}}
|
||||
onFocus={labelAnim.handleFocus}
|
||||
onBlur={labelAnim.handleBlur}
|
||||
hasTVPreferredFocus={isFirst && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale }] }}>
|
||||
<Animated.View style={labelAnim.animatedStyle}>
|
||||
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<Pressable
|
||||
onPress={onDecrease}
|
||||
onFocus={() => {
|
||||
setButtonFocused("minus");
|
||||
animateTo(minusScale, 1.1);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setButtonFocused(null);
|
||||
animateTo(minusScale, 1);
|
||||
}}
|
||||
onFocus={minusAnim.handleFocus}
|
||||
onBlur={minusAnim.handleBlur}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: minusScale }],
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor:
|
||||
buttonFocused === "minus" ? "#7c3aed" : "#4B5563",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
style={[
|
||||
minusAnim.animatedStyle,
|
||||
{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: minusAnim.focused ? "#7c3aed" : "#4B5563",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name='remove' size={24} color='#FFFFFF' />
|
||||
</Animated.View>
|
||||
@@ -266,27 +220,23 @@ const TVSettingsStepper: React.FC<{
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onIncrease}
|
||||
onFocus={() => {
|
||||
setButtonFocused("plus");
|
||||
animateTo(plusScale, 1.1);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setButtonFocused(null);
|
||||
animateTo(plusScale, 1);
|
||||
}}
|
||||
onFocus={plusAnim.handleFocus}
|
||||
onBlur={plusAnim.handleBlur}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: plusScale }],
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: buttonFocused === "plus" ? "#7c3aed" : "#4B5563",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
style={[
|
||||
plusAnim.animatedStyle,
|
||||
{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: plusAnim.focused ? "#7c3aed" : "#4B5563",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name='add' size={24} color='#FFFFFF' />
|
||||
</Animated.View>
|
||||
@@ -296,13 +246,6 @@ const TVSettingsStepper: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// Option item type for bottom sheet selector
|
||||
type TVSettingsOptionItem<T> = {
|
||||
label: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
// TV Settings Option Button - displays current value and opens bottom sheet
|
||||
const TVSettingsOptionButton: React.FC<{
|
||||
label: string;
|
||||
@@ -311,46 +254,34 @@ const TVSettingsOptionButton: React.FC<{
|
||||
isFirst?: boolean;
|
||||
disabled?: boolean;
|
||||
}> = ({ label, value, onPress, isFirst, disabled }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.02);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirst && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
@@ -370,179 +301,6 @@ const TVSettingsOptionButton: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// TV Settings Bottom Sheet - Apple TV style horizontal scrolling selector
|
||||
const TVSettingsBottomSheet = <T,>({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: TVSettingsOptionItem<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [options]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint='dark'
|
||||
style={{
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Horizontal options */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVSettingsOptionCard
|
||||
key={index}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Option card for horizontal bottom sheet selector (Apple TV style)
|
||||
const TVSettingsOptionCard: React.FC<{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
width: 160,
|
||||
height: 75,
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: selected
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused || selected ? "600" : "400",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{selected && !focused && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.8)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Section header component
|
||||
const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
<Text
|
||||
@@ -567,39 +325,27 @@ const TVLogoutButton: React.FC<{ onPress: () => void; disabled?: boolean }> = ({
|
||||
disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#ef4444",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
}}
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
shadowColor: "#ef4444",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
@@ -653,7 +399,7 @@ export default function SettingsTV() {
|
||||
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||
|
||||
// Audio transcoding options
|
||||
const audioTranscodeModeOptions = useMemo(
|
||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.audio.transcode_mode.auto"),
|
||||
@@ -680,7 +426,7 @@ export default function SettingsTV() {
|
||||
);
|
||||
|
||||
// Subtitle mode options
|
||||
const subtitleModeOptions = useMemo(
|
||||
const subtitleModeOptions: TVOptionItem<SubtitlePlaybackMode>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.subtitles.modes.Default"),
|
||||
@@ -712,7 +458,7 @@ export default function SettingsTV() {
|
||||
);
|
||||
|
||||
// MPV alignment options
|
||||
const alignXOptions = useMemo(
|
||||
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||
() => [
|
||||
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
||||
{
|
||||
@@ -725,7 +471,7 @@ export default function SettingsTV() {
|
||||
[currentAlignX],
|
||||
);
|
||||
|
||||
const alignYOptions = useMemo(
|
||||
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||
() => [
|
||||
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
||||
{
|
||||
@@ -936,28 +682,24 @@ export default function SettingsTV() {
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Bottom sheet modals */}
|
||||
<TVSettingsBottomSheet
|
||||
{/* Bottom sheet modals using shared TVOptionSelector */}
|
||||
<TVOptionSelector
|
||||
visible={openModal === "audioTranscode"}
|
||||
title={t("home.settings.audio.transcode_mode.title")}
|
||||
options={audioTranscodeModeOptions}
|
||||
onSelect={(value) =>
|
||||
updateSettings({ audioTranscodeMode: value as AudioTranscodeMode })
|
||||
}
|
||||
onSelect={(value) => updateSettings({ audioTranscodeMode: value })}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
<TVSettingsBottomSheet
|
||||
<TVOptionSelector
|
||||
visible={openModal === "subtitleMode"}
|
||||
title={t("home.settings.subtitles.subtitle_mode")}
|
||||
options={subtitleModeOptions}
|
||||
onSelect={(value) =>
|
||||
updateSettings({ subtitleMode: value as SubtitlePlaybackMode })
|
||||
}
|
||||
onSelect={(value) => updateSettings({ subtitleMode: value })}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
<TVSettingsBottomSheet
|
||||
<TVOptionSelector
|
||||
visible={openModal === "alignX"}
|
||||
title='Horizontal Alignment'
|
||||
options={alignXOptions}
|
||||
@@ -969,7 +711,7 @@ export default function SettingsTV() {
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
<TVSettingsBottomSheet
|
||||
<TVOptionSelector
|
||||
visible={openModal === "alignY"}
|
||||
title='Vertical Alignment'
|
||||
options={alignYOptions}
|
||||
|
||||
@@ -4,23 +4,15 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
BackHandler,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -34,6 +26,12 @@ 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 useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
@@ -60,558 +58,6 @@ interface ItemContentTVProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// Focusable button component for TV with Apple TV-style animations
|
||||
const TVFocusableButton: React.FC<{
|
||||
onPress: () => void;
|
||||
children: React.ReactNode;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
style?: any;
|
||||
variant?: "primary" | "secondary";
|
||||
}> = ({
|
||||
onPress,
|
||||
children,
|
||||
hasTVPreferredFocus,
|
||||
style,
|
||||
variant = "primary",
|
||||
}) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const isPrimary = variant === "primary";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
transform: [{ scale }],
|
||||
shadowColor: isPrimary ? "#fff" : "#a855f7",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: focused
|
||||
? isPrimary
|
||||
? "#ffffff"
|
||||
: "#7c3aed"
|
||||
: isPrimary
|
||||
? "rgba(255, 255, 255, 0.9)"
|
||||
: "rgba(124, 58, 237, 0.8)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: 180,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Info row component for metadata display
|
||||
const _InfoRow: React.FC<{ label: string; value: string }> = ({
|
||||
label,
|
||||
value,
|
||||
}) => (
|
||||
<View style={{ flexDirection: "row", marginBottom: 8 }}>
|
||||
<Text style={{ color: "#9CA3AF", fontSize: 16, width: 100 }}>{label}</Text>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 16, flex: 1 }}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Option item for the TV selector modal
|
||||
type TVOptionItem<T> = {
|
||||
label: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
// TV Option Selector (Modal style - saved as backup)
|
||||
const _TVOptionSelectorModal = <T,>({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: TVOptionItem<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
// Find the initially selected index
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [options]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#111",
|
||||
borderRadius: 16,
|
||||
paddingVertical: 24,
|
||||
paddingHorizontal: 32,
|
||||
minWidth: 420,
|
||||
maxWidth: SCREEN_WIDTH * 0.4,
|
||||
maxHeight: SCREEN_HEIGHT * 0.7,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 8,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Options list */}
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={{ maxHeight: SCREEN_HEIGHT * 0.5, overflow: "visible" }}
|
||||
contentContainerStyle={{ paddingVertical: 4, paddingHorizontal: 4 }}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<_TVOptionRowModal
|
||||
key={index}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Individual option row in the modal selector (backup)
|
||||
const _TVOptionRowModal: React.FC<{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.02);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
style={{ marginBottom: 2 }}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: focused ? "#2a2a2a" : "transparent",
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 28, marginRight: 12 }}>
|
||||
{selected && <Ionicons name='checkmark' size={22} color='#a855f7' />}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: focused || selected ? "#FFFFFF" : "#888",
|
||||
fontWeight: selected ? "600" : "400",
|
||||
flex: 1,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Cancel button for TV option selectors
|
||||
const TVCancelButton: React.FC<{ onPress: () => void }> = ({ onPress }) => {
|
||||
const { t } = useTranslation();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='close'
|
||||
size={20}
|
||||
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
||||
}}
|
||||
>
|
||||
{t("common.cancel") || "Cancel"}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// TV Option Selector - Bottom sheet with horizontal scrolling (Apple TV style)
|
||||
const TVOptionSelector = <T,>({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: TVOptionItem<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const firstCardRef = useRef<View>(null);
|
||||
|
||||
// Animation values
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [options]);
|
||||
|
||||
// Animate in when visible
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Reset values and animate in
|
||||
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();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
// Delay rendering to work around hasTVPreferredFocus timing issue
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
// Programmatic focus fallback
|
||||
useEffect(() => {
|
||||
if (isReady && firstCardRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
opacity: overlayOpacity,
|
||||
}}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ translateY: sheetTranslateY }] }}>
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint='dark'
|
||||
style={{
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={{
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Horizontal options */}
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={index}
|
||||
ref={
|
||||
index === initialSelectedIndex ? firstCardRef : undefined
|
||||
}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
{isReady && (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 16,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<TVCancelButton onPress={onClose} />
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
// Option card for horizontal selector (Apple TV style) - with forwardRef for programmatic focus
|
||||
const TVOptionCard = React.forwardRef<
|
||||
View,
|
||||
{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
width: 160,
|
||||
height: 75,
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: selected
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused || selected ? "600" : "400",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{selected && !focused && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.8)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
// Circular actor card with Apple TV style focus animations
|
||||
const TVActorCard = React.forwardRef<
|
||||
View,
|
||||
@@ -626,16 +72,8 @@ const TVActorCard = React.forwardRef<
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
>(({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
const imageUrl = person.Id
|
||||
? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90`
|
||||
@@ -645,28 +83,23 @@ const TVActorCard = React.forwardRef<
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.08);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
alignItems: "center",
|
||||
width: 120,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
}}
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
alignItems: "center",
|
||||
width: 120,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Circular image */}
|
||||
<View
|
||||
style={{
|
||||
width: 100,
|
||||
@@ -698,7 +131,6 @@ const TVActorCard = React.forwardRef<
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
@@ -712,7 +144,6 @@ const TVActorCard = React.forwardRef<
|
||||
{person.Name}
|
||||
</Text>
|
||||
|
||||
{/* Role */}
|
||||
{person.Role && (
|
||||
<Text
|
||||
style={{
|
||||
@@ -740,41 +171,28 @@ const TVSeriesSeasonCard: React.FC<{
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}> = ({ title, subtitle, imageUrl, onPress, hasTVPreferredFocus }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
width: 140,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
}}
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 140,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 16 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Poster image */}
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
@@ -806,7 +224,6 @@ const TVSeriesSeasonCard: React.FC<{
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
@@ -820,7 +237,6 @@ const TVSeriesSeasonCard: React.FC<{
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Subtitle */}
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={{
|
||||
@@ -850,39 +266,27 @@ const TVOptionButton = React.forwardRef<
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
>(({ label, value, onPress, hasTVPreferredFocus }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.02);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
}}
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
@@ -987,7 +391,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
const isModalOpen = openModal !== null;
|
||||
|
||||
// State for first actor card ref (used for focus guide)
|
||||
// Using state instead of useRef to trigger re-renders when ref is set
|
||||
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||
null,
|
||||
);
|
||||
@@ -1012,7 +415,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}, [isModalOpen]);
|
||||
|
||||
// tvOS menu button handler for closing modals
|
||||
// Note: This may not receive events if React Navigation intercepts them first
|
||||
useTVEventHandler((evt) => {
|
||||
if (!evt || !isModalOpen) return;
|
||||
if (evt.eventType === "menu" || evt.eventType === "back") {
|
||||
@@ -1042,7 +444,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}, [item, itemWithSources]);
|
||||
|
||||
// Audio options for selector
|
||||
const audioOptions = useMemo(() => {
|
||||
const audioOptions: TVOptionItem<number>[] = useMemo(() => {
|
||||
return audioTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle ||
|
||||
@@ -1053,7 +455,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}, [audioTracks, selectedOptions?.audioIndex]);
|
||||
|
||||
// Media source options for selector
|
||||
const mediaSourceOptions = useMemo(() => {
|
||||
const mediaSourceOptions: TVOptionItem<MediaSourceInfo>[] = useMemo(() => {
|
||||
return mediaSources.map((source) => {
|
||||
const videoStream = source.MediaStreams?.find(
|
||||
(s) => s.Type === "Video",
|
||||
@@ -1069,7 +471,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}, [mediaSources, selectedOptions?.mediaSource?.Id]);
|
||||
|
||||
// Quality/bitrate options for selector
|
||||
const qualityOptions = useMemo(() => {
|
||||
const qualityOptions: TVOptionItem<Bitrate>[] = useMemo(() => {
|
||||
return BITRATES.map((bitrate) => ({
|
||||
label: bitrate.key,
|
||||
value: bitrate,
|
||||
@@ -1092,7 +494,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
|
||||
const handleMediaSourceChange = useCallback(
|
||||
(mediaSource: MediaSourceInfo) => {
|
||||
// When media source changes, reset audio/subtitle to defaults
|
||||
const defaultAudio = mediaSource.MediaStreams?.find(
|
||||
(s) => s.Type === "Audio" && s.IsDefault,
|
||||
);
|
||||
@@ -1425,7 +826,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<TVFocusableButton
|
||||
<TVButton
|
||||
onPress={handlePlay}
|
||||
hasTVPreferredFocus
|
||||
variant='primary'
|
||||
@@ -1447,7 +848,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
? `${remainingTime} ${t("item_card.left")}`
|
||||
: t("common.play")}
|
||||
</Text>
|
||||
</TVFocusableButton>
|
||||
</TVButton>
|
||||
</View>
|
||||
|
||||
{/* Playback options */}
|
||||
|
||||
73
components/tv/TVButton.tsx
Normal file
73
components/tv/TVButton.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View, type ViewStyle } from "react-native";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVButtonProps {
|
||||
onPress: () => void;
|
||||
children: React.ReactNode;
|
||||
variant?: "primary" | "secondary";
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle;
|
||||
scaleAmount?: number;
|
||||
}
|
||||
|
||||
export const TVButton: React.FC<TVButtonProps> = ({
|
||||
onPress,
|
||||
children,
|
||||
variant = "primary",
|
||||
hasTVPreferredFocus = false,
|
||||
disabled = false,
|
||||
style,
|
||||
scaleAmount = 1.05,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount });
|
||||
|
||||
const isPrimary = variant === "primary";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
shadowColor: isPrimary ? "#fff" : "#a855f7",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: focused
|
||||
? isPrimary
|
||||
? "#ffffff"
|
||||
: "#7c3aed"
|
||||
: isPrimary
|
||||
? "rgba(255, 255, 255, 0.9)"
|
||||
: "rgba(124, 58, 237, 0.8)",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: 180,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
60
components/tv/TVCancelButton.tsx
Normal file
60
components/tv/TVCancelButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVCancelButtonProps {
|
||||
onPress: () => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
|
||||
onPress,
|
||||
label = "Cancel",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name='close'
|
||||
size={20}
|
||||
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
102
components/tv/TVOptionCard.tsx
Normal file
102
components/tv/TVOptionCard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVOptionCardProps {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
sublabel,
|
||||
selected,
|
||||
hasTVPreferredFocus = false,
|
||||
onPress,
|
||||
width = 160,
|
||||
height = 75,
|
||||
},
|
||||
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={[
|
||||
animatedStyle,
|
||||
{
|
||||
width,
|
||||
height,
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: selected
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused || selected ? "600" : "400",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{sublabel && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
||||
textAlign: "center",
|
||||
marginTop: 2,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{sublabel}
|
||||
</Text>
|
||||
)}
|
||||
{selected && !focused && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.8)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
199
components/tv/TVOptionSelector.tsx
Normal file
199
components/tv/TVOptionSelector.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVCancelButton } from "./TVCancelButton";
|
||||
import { TVOptionCard } from "./TVOptionCard";
|
||||
|
||||
export type TVOptionItem<T> = {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export interface TVOptionSelectorProps<T> {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: TVOptionItem<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
cancelLabel?: string;
|
||||
cardWidth?: number;
|
||||
cardHeight?: number;
|
||||
}
|
||||
|
||||
export const TVOptionSelector = <T,>({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
onSelect,
|
||||
onClose,
|
||||
cancelLabel = "Cancel",
|
||||
cardWidth = 160,
|
||||
cardHeight = 75,
|
||||
}: TVOptionSelectorProps<T>) => {
|
||||
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(() => {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
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();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && firstCardRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
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={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{isReady && (
|
||||
<View style={styles.cancelButtonContainer}>
|
||||
<TVCancelButton onPress={onClose} label={cancelLabel} />
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
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,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
68
components/tv/TVTabButton.tsx
Normal file
68
components/tv/TVTabButton.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { Animated, Pressable } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVTabButtonProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
switchOnFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVTabButton: React.FC<TVTabButtonProps> = ({
|
||||
label,
|
||||
active,
|
||||
onSelect,
|
||||
hasTVPreferredFocus = false,
|
||||
switchOnFocus = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
scaleAmount: 1.05,
|
||||
duration: 120,
|
||||
onFocus: switchOnFocus ? onSelect : undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onSelect}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: active
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "transparent",
|
||||
borderBottomColor: active ? "#fff" : "transparent",
|
||||
borderBottomWidth: 2,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused || active ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
61
components/tv/hooks/useTVFocusAnimation.ts
Normal file
61
components/tv/hooks/useTVFocusAnimation.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Animated, Easing } from "react-native";
|
||||
|
||||
export interface UseTVFocusAnimationOptions {
|
||||
scaleAmount?: number;
|
||||
duration?: number;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export interface UseTVFocusAnimationReturn {
|
||||
focused: boolean;
|
||||
scale: Animated.Value;
|
||||
handleFocus: () => void;
|
||||
handleBlur: () => void;
|
||||
animatedStyle: { transform: { scale: Animated.Value }[] };
|
||||
}
|
||||
|
||||
export const useTVFocusAnimation = ({
|
||||
scaleAmount = 1.05,
|
||||
duration = 150,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = useCallback(
|
||||
(value: number) => {
|
||||
Animated.timing(scale, {
|
||||
toValue: value,
|
||||
duration,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
},
|
||||
[scale, duration],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
animateTo(scaleAmount);
|
||||
onFocus?.();
|
||||
}, [animateTo, scaleAmount, onFocus]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
onBlur?.();
|
||||
}, [animateTo, onBlur]);
|
||||
|
||||
const animatedStyle = { transform: [{ scale }] };
|
||||
|
||||
return {
|
||||
focused,
|
||||
scale,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
animatedStyle,
|
||||
};
|
||||
};
|
||||
17
components/tv/index.ts
Normal file
17
components/tv/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type {
|
||||
UseTVFocusAnimationOptions,
|
||||
UseTVFocusAnimationReturn,
|
||||
} from "./hooks/useTVFocusAnimation";
|
||||
export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
export type { TVButtonProps } from "./TVButton";
|
||||
export { TVButton } from "./TVButton";
|
||||
export type { TVCancelButtonProps } from "./TVCancelButton";
|
||||
export { TVCancelButton } from "./TVCancelButton";
|
||||
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
|
||||
export { TVFocusablePoster } from "./TVFocusablePoster";
|
||||
export type { TVOptionCardProps } from "./TVOptionCard";
|
||||
export { TVOptionCard } from "./TVOptionCard";
|
||||
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
|
||||
export { TVOptionSelector } from "./TVOptionSelector";
|
||||
export type { TVTabButtonProps } from "./TVTabButton";
|
||||
export { TVTabButton } from "./TVTabButton";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,15 +14,20 @@ import React, {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
Animated as RNAnimated,
|
||||
Easing as RNEasing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
TVCancelButton,
|
||||
TVTabButton,
|
||||
useTVFocusAnimation,
|
||||
} from "@/components/tv";
|
||||
import {
|
||||
type SubtitleSearchResult,
|
||||
useRemoteSubtitles,
|
||||
@@ -33,84 +38,16 @@ interface TVSubtitleSheetProps {
|
||||
visible: boolean;
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
|
||||
// Existing subtitle tracks from media source
|
||||
subtitleTracks: MediaStream[];
|
||||
currentSubtitleIndex: number;
|
||||
|
||||
// Track selection callback
|
||||
onSubtitleIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
|
||||
// Optional - for during-playback context only
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
}
|
||||
|
||||
type TabType = "tracks" | "download";
|
||||
|
||||
// Tab button component - requires press to switch
|
||||
const TVTabButton: React.FC<{
|
||||
label: string;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}> = ({ label, active, onSelect, hasTVPreferredFocus, disabled }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
RNAnimated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onSelect}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
styles.tabButton,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: active
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "transparent",
|
||||
borderBottomColor: active ? "#fff" : "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
{ color: focused ? "#000" : "#fff" },
|
||||
(focused || active) && { fontWeight: "600" },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</RNAnimated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Track card for subtitle track selection
|
||||
const TVTrackCard = React.forwardRef<
|
||||
View,
|
||||
@@ -122,36 +59,22 @@ const TVTrackCard = React.forwardRef<
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
RNAnimated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<RNAnimated.View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.trackCard,
|
||||
animatedStyle,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: selected
|
||||
@@ -190,7 +113,7 @@ const TVTrackCard = React.forwardRef<
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</RNAnimated.View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
@@ -206,36 +129,22 @@ const LanguageCard = React.forwardRef<
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
RNAnimated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<RNAnimated.View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.languageCard,
|
||||
animatedStyle,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: selected
|
||||
@@ -271,7 +180,7 @@ const LanguageCard = React.forwardRef<
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</RNAnimated.View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
@@ -286,37 +195,23 @@ const SubtitleResultCard = React.forwardRef<
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
RNAnimated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.03);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<RNAnimated.View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.resultCard,
|
||||
animatedStyle,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
|
||||
borderColor: focused
|
||||
? "rgba(255,255,255,0.8)"
|
||||
@@ -469,66 +364,11 @@ const SubtitleResultCard = React.forwardRef<
|
||||
<ActivityIndicator size='small' color='#fff' />
|
||||
</View>
|
||||
)}
|
||||
</RNAnimated.View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
// Cancel button for TV subtitle sheet
|
||||
const TVCancelButton: React.FC<{ onPress: () => void; label: string }> = ({
|
||||
onPress,
|
||||
label,
|
||||
}) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
RNAnimated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
>
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
styles.cancelButton,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name='close'
|
||||
size={20}
|
||||
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.cancelButtonText,
|
||||
{ color: focused ? "#000" : "rgba(255,255,255,0.8)" },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</RNAnimated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
visible,
|
||||
item,
|
||||
@@ -563,47 +403,42 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
mediaSourceId,
|
||||
});
|
||||
|
||||
// Store reset in a ref to avoid dependency issues
|
||||
const resetRef = useRef(reset);
|
||||
resetRef.current = reset;
|
||||
|
||||
// Animation values
|
||||
const overlayOpacity = useRef(new RNAnimated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new RNAnimated.Value(300)).current;
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
// Determine initial selected track index
|
||||
const initialSelectedTrackIndex = useMemo(() => {
|
||||
if (currentSubtitleIndex === -1) return 0; // "None" option
|
||||
if (currentSubtitleIndex === -1) return 0;
|
||||
const trackIdx = subtitleTracks.findIndex(
|
||||
(t) => t.Index === currentSubtitleIndex,
|
||||
);
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0; // +1 because "None" is at index 0
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||
}, [subtitleTracks, currentSubtitleIndex]);
|
||||
|
||||
// Animate in/out
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(300);
|
||||
|
||||
RNAnimated.parallel([
|
||||
RNAnimated.timing(overlayOpacity, {
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
RNAnimated.timing(sheetTranslateY, {
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: RNEasing.out(RNEasing.cubic),
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
// Reset state when sheet closes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setHasSearchedThisSession(false);
|
||||
@@ -613,7 +448,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Delay rendering to work around hasTVPreferredFocus timing issue
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
@@ -622,7 +456,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
// Lazy loading: search when Download tab is first activated
|
||||
useEffect(() => {
|
||||
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||
search({ language: selectedLanguage });
|
||||
@@ -630,7 +463,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
}
|
||||
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
|
||||
|
||||
// Delay tab content rendering to prevent focus conflicts when switching tabs
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsTabContentReady(false);
|
||||
@@ -640,7 +472,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
setIsTabContentReady(false);
|
||||
}, [activeTab, isReady]);
|
||||
|
||||
// Handle language selection
|
||||
const handleLanguageSelect = useCallback(
|
||||
(code: string) => {
|
||||
setSelectedLanguage(code);
|
||||
@@ -649,7 +480,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
[search],
|
||||
);
|
||||
|
||||
// Handle track selection
|
||||
const handleTrackSelect = useCallback(
|
||||
(index: number) => {
|
||||
onSubtitleIndexChange(index);
|
||||
@@ -658,7 +488,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
[onSubtitleIndexChange, onClose],
|
||||
);
|
||||
|
||||
// Handle subtitle download
|
||||
const handleDownload = useCallback(
|
||||
async (result: SubtitleSearchResult) => {
|
||||
setDownloadingId(result.id);
|
||||
@@ -687,13 +516,11 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
// Subset of common languages for TV
|
||||
const displayLanguages = useMemo(
|
||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||
[],
|
||||
);
|
||||
|
||||
// Track options with "None" at the start
|
||||
const trackOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
@@ -714,8 +541,8 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<RNAnimated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<RNAnimated.View
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
@@ -914,8 +741,8 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</RNAnimated.View>
|
||||
</RNAnimated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -956,15 +783,6 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
gap: 24,
|
||||
},
|
||||
tabButton: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
borderBottomWidth: 2,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 18,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
@@ -1156,16 +974,4 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 20,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
cancelButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
gap: 8,
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user