mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-16 10:50:28 +01:00
refactor(tv): use shared components and proper typography in actor page
This commit is contained in:
@@ -19,17 +19,19 @@ import {
|
|||||||
Dimensions,
|
Dimensions,
|
||||||
Easing,
|
Easing,
|
||||||
FlatList,
|
FlatList,
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster.tv";
|
import MoviePoster from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import { TVItemCardText } from "@/components/tv/TVItemCardText";
|
||||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -44,64 +46,6 @@ const ACTOR_IMAGE_SIZE = 250;
|
|||||||
const ITEM_GAP = 16;
|
const ITEM_GAP = 16;
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
// Focusable poster wrapper component for TV
|
|
||||||
const TVFocusablePoster: React.FC<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
onPress: () => void;
|
|
||||||
onLongPress?: () => void;
|
|
||||||
hasTVPreferredFocus?: boolean;
|
|
||||||
onFocus?: () => void;
|
|
||||||
onBlur?: () => void;
|
|
||||||
}> = ({
|
|
||||||
children,
|
|
||||||
onPress,
|
|
||||||
onLongPress,
|
|
||||||
hasTVPreferredFocus,
|
|
||||||
onFocus,
|
|
||||||
onBlur,
|
|
||||||
}) => {
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
|
||||||
|
|
||||||
const animateTo = (value: number) =>
|
|
||||||
Animated.timing(scale, {
|
|
||||||
toValue: value,
|
|
||||||
duration: 150,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={onPress}
|
|
||||||
onLongPress={onLongPress}
|
|
||||||
onFocus={() => {
|
|
||||||
setFocused(true);
|
|
||||||
animateTo(1.05);
|
|
||||||
onFocus?.();
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setFocused(false);
|
|
||||||
animateTo(1);
|
|
||||||
onBlur?.();
|
|
||||||
}}
|
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
transform: [{ scale }],
|
|
||||||
shadowColor: "#ffffff",
|
|
||||||
shadowOffset: { width: 0, height: 0 },
|
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
|
||||||
shadowRadius: focused ? 20 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TVActorPageProps {
|
interface TVActorPageProps {
|
||||||
personId: string;
|
personId: string;
|
||||||
}
|
}
|
||||||
@@ -114,6 +58,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -294,29 +239,48 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render filmography item
|
// Render movie filmography item
|
||||||
const renderFilmographyItem = useCallback(
|
const renderMovieItem = useCallback(
|
||||||
(
|
({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
|
||||||
{ item: filmItem, index }: { item: BaseItemDto; index: number },
|
|
||||||
isFirstSection: boolean,
|
|
||||||
) => (
|
|
||||||
<View style={{ marginRight: ITEM_GAP }}>
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
<TVFocusablePoster
|
<TVFocusablePoster
|
||||||
onPress={() => handleItemPress(filmItem)}
|
onPress={() => handleItemPress(filmItem)}
|
||||||
onLongPress={() => showItemActions(filmItem)}
|
onLongPress={() => showItemActions(filmItem)}
|
||||||
onFocus={() => setFocusedItem(filmItem)}
|
onFocus={() => setFocusedItem(filmItem)}
|
||||||
hasTVPreferredFocus={isFirstSection && index === 0}
|
hasTVPreferredFocus={index === 0}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<MoviePoster item={filmItem} />
|
<MoviePoster item={filmItem} />
|
||||||
<View style={{ width: posterSizes.poster, marginTop: 8 }}>
|
<View style={{ width: posterSizes.poster }}>
|
||||||
<ItemCardText item={filmItem} />
|
<TVItemCardText item={filmItem} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
[handleItemPress, showItemActions],
|
[handleItemPress, showItemActions, posterSizes.poster],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render series filmography item
|
||||||
|
const renderSeriesItem = useCallback(
|
||||||
|
({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(filmItem)}
|
||||||
|
onLongPress={() => showItemActions(filmItem)}
|
||||||
|
onFocus={() => setFocusedItem(filmItem)}
|
||||||
|
hasTVPreferredFocus={movies.length === 0 && index === 0}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<SeriesPoster item={filmItem} />
|
||||||
|
<View style={{ width: posterSizes.poster }}>
|
||||||
|
<TVItemCardText item={filmItem} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TVFocusablePoster>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleItemPress, showItemActions, posterSizes.poster, movies.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoadingActor) {
|
if (isLoadingActor) {
|
||||||
@@ -386,28 +350,16 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{/* Gradient overlays for readability */}
|
{/* Gradient overlay for readability */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
locations={[0, 0.5, 1]}
|
locations={[0, 0.4, 1]}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
height: "70%",
|
height: "100%",
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,0.8)", "transparent"]}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 0.6, y: 0 }}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: "60%",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -471,7 +423,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
{/* Actor name */}
|
{/* Actor name */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 42,
|
fontSize: typography.title,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -485,7 +437,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
{item.ProductionYear && (
|
{item.ProductionYear && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
@@ -498,9 +450,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
{item.Overview && (
|
{item.Overview && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
fontSize: typography.body,
|
||||||
color: "#D1D5DB",
|
color: "#D1D5DB",
|
||||||
lineHeight: 28,
|
lineHeight: typography.body * 1.4,
|
||||||
maxWidth: SCREEN_WIDTH * 0.45,
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
}}
|
}}
|
||||||
numberOfLines={4}
|
numberOfLines={4}
|
||||||
@@ -529,7 +481,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
<View style={{ marginBottom: 32 }}>
|
<View style={{ marginBottom: 32 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 22,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -542,7 +494,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
horizontal
|
horizontal
|
||||||
data={movies}
|
data={movies}
|
||||||
keyExtractor={(filmItem) => filmItem.Id!}
|
keyExtractor={(filmItem) => filmItem.Id!}
|
||||||
renderItem={(props) => renderFilmographyItem(props, true)}
|
renderItem={renderMovieItem}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
initialNumToRender={6}
|
initialNumToRender={6}
|
||||||
maxToRenderPerBatch={4}
|
maxToRenderPerBatch={4}
|
||||||
@@ -575,7 +527,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 22,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -588,9 +540,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
horizontal
|
horizontal
|
||||||
data={series}
|
data={series}
|
||||||
keyExtractor={(filmItem) => filmItem.Id!}
|
keyExtractor={(filmItem) => filmItem.Id!}
|
||||||
renderItem={(props) =>
|
renderItem={renderSeriesItem}
|
||||||
renderFilmographyItem(props, movies.length === 0)
|
|
||||||
}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
initialNumToRender={6}
|
initialNumToRender={6}
|
||||||
maxToRenderPerBatch={4}
|
maxToRenderPerBatch={4}
|
||||||
@@ -615,7 +565,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#737373",
|
color: "#737373",
|
||||||
fontSize: 16,
|
fontSize: typography.callout,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user