mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-29 20:06:29 +01:00
refactor(tv): extract shared components to reduce code duplication
This commit is contained in:
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";
|
||||
Reference in New Issue
Block a user