This commit is contained in:
zimplexing
2025-06-27 16:16:14 +08:00
commit 7e6095d2bb
111 changed files with 20915 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import Ionicons from '@expo/vector-icons/Ionicons';
import {PropsWithChildren, useState} from 'react';
import {StyleSheet, TouchableOpacity, useColorScheme} from 'react-native';
import {ThemedText} from '@/components/ThemedText';
import {ThemedView} from '@/components/ThemedView';
import {Colors} from '@/constants/Colors';
export function Collapsible({
children,
title,
}: PropsWithChildren & {title: string}) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.6}
>
<Ionicons
name={isOpen ? 'chevron-down' : 'chevron-forward-outline'}
size={18}
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View File

@@ -0,0 +1,246 @@
import {
StyleSheet,
Text,
View,
useTVEventHandler,
Platform,
Pressable,
TouchableHighlight,
TouchableNativeFeedback,
TouchableOpacity,
GestureResponderEvent,
} from 'react-native';
import {useState} from 'react';
import {ThemedText} from '@/components/ThemedText';
import {ThemedView} from '@/components/ThemedView';
import {useScale} from '@/hooks/useScale';
import {useThemeColor} from '@/hooks/useThemeColor';
export function EventHandlingDemo() {
const [remoteEventLog, setRemoteEventLog] = useState<string[]>([]);
const [pressableEventLog, setPressableEventLog] = useState<string[]>([]);
const logWithAppendedEntry = (log: string[], entry: string) => {
const limit = 3;
const newEventLog = log.slice(0, limit - 1);
newEventLog.unshift(entry);
return newEventLog;
};
const updatePressableLog = (entry: string) => {
setPressableEventLog((log) => logWithAppendedEntry(log, entry));
};
useTVEventHandler((event) => {
const {eventType, eventKeyAction} = event;
if (eventType !== 'focus' && eventType !== 'blur') {
setRemoteEventLog((log) =>
logWithAppendedEntry(
log,
`type=${eventType}, action=${
eventKeyAction !== undefined ? eventKeyAction : ''
}`,
),
);
}
});
const styles = useDemoStyles();
return (
<ThemedView style={styles.container}>
<PressableButton title="Pressable" log={updatePressableLog} />
<TouchableOpacityButton
title="TouchableOpacity"
log={updatePressableLog}
/>
<TouchableHighlightButton
title="TouchableHighlight"
log={updatePressableLog}
/>
{Platform.OS === 'android' ? (
<TouchableNativeFeedbackButton
title="TouchableNativeFeedback"
log={updatePressableLog}
/>
) : null}
<ThemedView style={styles.logContainer}>
<View>
<ThemedText type="defaultSemiBold">Focus/press events</ThemedText>
<ThemedText style={styles.logText}>
{remoteEventLog.join('\n')}
</ThemedText>
</View>
<View>
<ThemedText type="defaultSemiBold">Remote control events</ThemedText>
<ThemedText style={styles.logText}>
{pressableEventLog.join('\n')}
</ThemedText>
</View>
</ThemedView>
</ThemedView>
);
}
const PressableButton = (props: {
title: string;
log: (entry: string) => void;
}) => {
const styles = useDemoStyles();
return (
<Pressable
onFocus={() => props.log(`${props.title} focus`)}
onBlur={() => props.log(`${props.title} blur`)}
onPress={() => props.log(`${props.title} pressed`)}
onLongPress={(event: GestureResponderEvent & {eventKeyAction?: number}) =>
props.log(
`${props.title} long press ${
event.eventKeyAction === 0 ? 'start' : 'end'
}`,
)
}
style={({pressed, focused}) =>
pressed || focused ? styles.pressableFocused : styles.pressable
}
>
{({focused}) => {
return (
<ThemedText style={styles.pressableText}>
{focused ? `${props.title} focused` : props.title}
</ThemedText>
);
}}
</Pressable>
);
};
const TouchableOpacityButton = (props: {
title: string;
log: (entry: string) => void;
}) => {
const styles = useDemoStyles();
return (
<TouchableOpacity
activeOpacity={0.6}
style={styles.pressable}
onFocus={() => props.log(`${props.title} focus`)}
onBlur={() => props.log(`${props.title} blur`)}
onPress={() => props.log(`${props.title} pressed`)}
onLongPress={(event: GestureResponderEvent & {eventKeyAction?: number}) =>
props.log(
`${props.title} long press ${
event.eventKeyAction === 0 ? 'start' : 'end'
}`,
)
}
>
<Text style={styles.pressableText}>{props.title}</Text>
</TouchableOpacity>
);
};
const TouchableHighlightButton = (props: {
title: string;
log: (entry: string) => void;
}) => {
const styles = useDemoStyles();
const underlayColor = useThemeColor({}, 'tint');
return (
<TouchableHighlight
style={styles.pressable}
underlayColor={underlayColor}
onFocus={() => props.log(`${props.title} focus`)}
onBlur={() => props.log(`${props.title} blur`)}
onPress={() => props.log(`${props.title} pressed`)}
onLongPress={(event: GestureResponderEvent & {eventKeyAction?: number}) =>
props.log(
`${props.title} long press ${
event.eventKeyAction === 0 ? 'start' : 'end'
}`,
)
}
>
<Text style={styles.pressableText}>{props.title}</Text>
</TouchableHighlight>
);
};
const TouchableNativeFeedbackButton = (props: {
title: string;
log: (entry: string) => void;
}) => {
const styles = useDemoStyles();
return (
<TouchableNativeFeedback
background={TouchableNativeFeedback.SelectableBackground()}
onFocus={() => props.log(`${props.title} focus`)}
onBlur={() => props.log(`${props.title} blur`)}
onPress={() => props.log(`${props.title} pressed`)}
onLongPress={(event: GestureResponderEvent & {eventKeyAction?: number}) =>
props.log(
`${props.title} long press ${
event.eventKeyAction === 0 ? 'start' : 'end'
}`,
)
}
>
<View style={styles.pressable}>
<Text style={styles.pressableText}>{props.title}</Text>
</View>
</TouchableNativeFeedback>
);
};
const useDemoStyles = function () {
const scale = useScale();
const highlightColor = useThemeColor({}, 'link');
const backgroundColor = useThemeColor({}, 'background');
const tintColor = useThemeColor({}, 'tint');
const textColor = useThemeColor({}, 'text');
return StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
logContainer: {
flexDirection: 'row',
padding: 5 * scale,
margin: 5 * scale,
alignItems: 'flex-start',
justifyContent: 'flex-start',
},
logText: {
height: 100 * scale,
width: 200 * scale,
fontSize: 10 * scale,
margin: 5 * scale,
alignSelf: 'flex-start',
justifyContent: 'flex-start',
},
pressable: {
borderColor: highlightColor,
backgroundColor: textColor,
borderWidth: 1,
borderRadius: 5 * scale,
margin: 5 * scale,
},
pressableFocused: {
borderColor: highlightColor,
backgroundColor: tintColor,
borderWidth: 1,
borderRadius: 5 * scale,
margin: 5 * scale,
},
pressableText: {
color: backgroundColor,
fontSize: 15 * scale,
},
});
};

View File

@@ -0,0 +1,24 @@
import {Link} from 'expo-router';
import {openBrowserAsync} from 'expo-web-browser';
import {type ComponentProps} from 'react';
import {Platform} from 'react-native';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & {href: string};
export function ExternalLink({href, ...rest}: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href);
}
}}
/>
);
}

View File

@@ -0,0 +1,21 @@
import {Link} from 'expo-router';
import {type ComponentProps} from 'react';
import {Pressable, Linking} from 'react-native';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & {href: string};
export function ExternalLink({href, ...rest}: Props) {
// On TV, use a Pressable (which handles focus navigation) instead of the Link component
return (
<Pressable
onPress={() =>
Linking.openURL(href).catch((reason) => alert(`${reason}`))
}
style={({pressed, focused}) => ({
opacity: pressed || focused ? 0.6 : 1.0,
})}
>
{rest.children}
</Pressable>
);
}

40
components/HelloWave.tsx Normal file
View File

@@ -0,0 +1,40 @@
import {StyleSheet} from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
} from 'react-native-reanimated';
import {ThemedText} from '@/components/ThemedText';
export function HelloWave() {
const rotationAnimation = useSharedValue(0);
rotationAnimation.value = withRepeat(
withSequence(
withTiming(25, {duration: 150}),
withTiming(0, {duration: 150}),
),
4, // Run the animation 4 times
);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{rotate: `${rotationAnimation.value}deg`}],
}));
return (
<Animated.View style={animatedStyle}>
<ThemedText style={styles.text}>👋</ThemedText>
</Animated.View>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 28,
lineHeight: 32,
marginTop: -6,
},
});

View File

@@ -0,0 +1,87 @@
import type {PropsWithChildren, ReactElement} from 'react';
import {StyleSheet, useColorScheme} from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from 'react-native-reanimated';
import {ThemedView} from '@/components/ThemedView';
import {useScale} from '@/hooks/useScale';
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: {dark: string; light: string};
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
const scale = useScale();
const styles = useParallaxScrollViewStyles();
const HEADER_HEIGHT = 125 * scale;
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
),
},
{
scale: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1],
),
},
],
};
});
return (
<ThemedView style={styles.container}>
<Animated.ScrollView ref={scrollRef} scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{backgroundColor: headerBackgroundColor[colorScheme]},
headerAnimatedStyle,
]}
>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
</ThemedView>
);
}
const useParallaxScrollViewStyles = function () {
const scale = useScale();
return StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 125 * scale,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32 * scale,
gap: 16 * scale,
overflow: 'hidden',
},
});
};

View File

@@ -0,0 +1,67 @@
import React from "react";
import { View, FlatList, StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import VideoCard from "./VideoCard.tv"; // Note the .tv import
import { MoonTVAPI } from "@/services/api";
export interface RowItem {
id: string;
source: string;
title: string;
poster: string;
year?: string;
rate?: string;
// Add any other properties that VideoCard might need from the data item
}
interface ScrollableRowProps {
title: string;
data: RowItem[];
api: MoonTVAPI;
}
export default function ScrollableRow({
title,
data,
api,
}: ScrollableRowProps) {
return (
<View style={styles.container}>
<ThemedText type="subtitle" style={styles.title}>
{title}
</ThemedText>
<FlatList
data={data}
renderItem={({ item }) => (
<VideoCard
id={item.id}
source={item.source}
title={item.title}
poster={item.poster}
year={item.year}
rate={item.rate}
api={api}
/>
)}
keyExtractor={(item) => `${item.source}-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContent}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 24,
},
title: {
marginLeft: 16,
marginBottom: 12,
},
listContent: {
paddingLeft: 8,
paddingRight: 16,
},
});

36
components/ThemedText.tsx Normal file
View File

@@ -0,0 +1,36 @@
import {Text, type TextProps} from 'react-native';
import {useThemeColor} from '@/hooks/useThemeColor';
import {useTextStyles} from '@/hooks/useTextStyles';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({light: lightColor, dark: darkColor}, 'text');
const styles = useTextStyles();
return (
<Text
style={[
{color},
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}

22
components/ThemedView.tsx Normal file
View File

@@ -0,0 +1,22 @@
import {View, type ViewProps} from 'react-native';
import {useThemeColor} from '@/hooks/useThemeColor';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({
style,
lightColor,
darkColor,
...otherProps
}: ThemedViewProps) {
const backgroundColor = useThemeColor(
{light: lightColor, dark: darkColor},
'background',
);
return <View style={[{backgroundColor}, style]} {...otherProps} />;
}

191
components/VideoCard.tv.tsx Normal file
View File

@@ -0,0 +1,191 @@
import React, { useState, useEffect, useCallback } from "react";
import { View, Text, Image, StyleSheet, Pressable } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from "react-native-reanimated";
import { useRouter } from "expo-router";
import { Heart, Star } from "lucide-react-native";
import { FavoriteManager } from "@/services/storage";
import { MoonTVAPI } from "@/services/api";
interface VideoCardProps {
id: string;
source: string;
title: string;
poster: string;
year?: string;
rate?: string;
onFocus?: () => void;
api: MoonTVAPI;
}
export default function VideoCard({
id,
source,
title,
poster,
year,
rate,
onFocus,
api,
}: VideoCardProps) {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: scale.value }],
};
});
useEffect(() => {
const checkFavorite = async () => {
const fav = await FavoriteManager.isFavorited(source, id);
setIsFavorited(fav);
};
checkFavorite();
}, [source, id]);
const handlePress = () => {
router.push({
pathname: "/detail",
params: { source, id },
});
};
const handleFocus = useCallback(() => {
setIsFocused(true);
scale.value = withSpring(1.05, { damping: 15, stiffness: 200 });
onFocus?.();
}, [scale, onFocus]);
const handleBlur = useCallback(() => {
setIsFocused(false);
scale.value = withSpring(1.0);
}, [scale]);
const handleToggleFavorite = async () => {
const newFavState = await FavoriteManager.toggle(source, id, {
title,
poster,
source_name: source,
});
setIsFavorited(newFavState);
};
return (
<Animated.View style={[styles.wrapper, animatedStyle]}>
<Pressable
onPress={handlePress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
>
<View style={styles.card}>
<Image
source={{ uri: api.getImageProxyUrl(poster) }}
style={styles.poster}
/>
{isFocused && (
<View style={styles.overlay}>
<Pressable
onPress={handleToggleFavorite}
style={styles.favButton}
>
<Heart
size={24}
color={isFavorited ? "red" : "white"}
fill={isFavorited ? "red" : "transparent"}
/>
</Pressable>
</View>
)}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<Text style={styles.ratingText}>{rate}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{year && <Text style={styles.year}>{year}</Text>}
</View>
</Pressable>
</Animated.View>
);
}
const CARD_WIDTH = 160;
const CARD_HEIGHT = 240;
const styles = StyleSheet.create({
wrapper: {
marginHorizontal: 8,
},
pressable: {
alignItems: "center",
},
card: {
width: CARD_WIDTH,
height: CARD_HEIGHT,
borderRadius: 8,
backgroundColor: "#222",
overflow: "hidden",
},
poster: {
width: "100%",
height: "100%",
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: "center",
alignItems: "center",
},
favButton: {
position: "absolute",
top: 8,
left: 8,
},
ratingContainer: {
position: "absolute",
top: 8,
right: 8,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
ratingText: {
color: "#FFD700",
fontSize: 12,
fontWeight: "bold",
marginLeft: 4,
},
infoContainer: {
width: CARD_WIDTH,
marginTop: 8,
alignItems: "center",
},
title: {
color: "white",
fontSize: 16,
fontWeight: "bold",
textAlign: "center",
},
year: {
color: "#aaa",
fontSize: 12,
textAlign: "center",
},
});

View File

@@ -0,0 +1,12 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import {ThemedText} from '../ThemedText';
it(`renders correctly`, () => {
const tree = renderer
.create(<ThemedText>Snapshot test!</ThemedText>)
.toJSON();
expect(tree).toMatchSnapshot();
});

View File

@@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Text
style={
[
{
"color": "#11181C",
},
{
"fontSize": 16,
"lineHeight": 24,
},
undefined,
undefined,
undefined,
undefined,
undefined,
]
}
>
Snapshot test!
</Text>
`;

View File

@@ -0,0 +1,12 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import Ionicons from '@expo/vector-icons/Ionicons';
import {type IconProps} from '@expo/vector-icons/build/createIconSet';
import {type ComponentProps} from 'react';
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>['name']>) {
return <Ionicons size={28} style={[{marginBottom: -3}, style]} {...rest} />;
}