This commit is contained in:
zimplexing
2025-07-02 09:09:35 +08:00
parent 7e6095d2bb
commit 6b51cd0a19
86 changed files with 2440 additions and 8770 deletions

View File

@@ -1,45 +0,0 @@
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,55 @@
import React from "react";
import {
Pressable,
StyleSheet,
StyleProp,
ViewStyle,
PressableProps,
} from "react-native";
interface DetailButtonProps extends PressableProps {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
export const DetailButton: React.FC<DetailButtonProps> = ({
children,
style,
...rest
}) => {
return (
<Pressable
style={({ focused }) => [
styles.button,
style,
focused && styles.buttonFocused,
]}
{...rest}
>
{children}
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#333",
paddingHorizontal: 15,
paddingVertical: 10,
borderRadius: 8,
margin: 5,
borderWidth: 2,
borderColor: "transparent",
flexDirection: "row",
alignItems: "center",
},
buttonFocused: {
backgroundColor: "#0056b3",
borderColor: "#fff",
elevation: 5,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 15,
},
});

View File

@@ -0,0 +1,188 @@
import React from "react";
import {
View,
Text,
StyleSheet,
Modal,
FlatList,
Pressable,
TouchableOpacity,
} from "react-native";
interface Episode {
title?: string;
url: string;
}
interface EpisodeSelectionModalProps {
visible: boolean;
episodes: Episode[];
currentEpisodeIndex: number;
episodeGroupSize: number;
selectedEpisodeGroup: number;
setSelectedEpisodeGroup: (group: number) => void;
onSelectEpisode: (index: number) => void;
onClose: () => void;
}
export const EpisodeSelectionModal: React.FC<EpisodeSelectionModalProps> = ({
visible,
episodes,
currentEpisodeIndex,
episodeGroupSize,
selectedEpisodeGroup,
setSelectedEpisodeGroup,
onSelectEpisode,
onClose,
}) => {
return (
<Modal
visible={visible}
transparent={true}
animationType="slide"
onRequestClose={onClose}
>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
{episodes.length > episodeGroupSize && (
<View style={styles.episodeGroupContainer}>
{Array.from(
{ length: Math.ceil(episodes.length / episodeGroupSize) },
(_, groupIndex) => (
<TouchableOpacity
key={groupIndex}
style={[
styles.episodeGroupButton,
selectedEpisodeGroup === groupIndex &&
styles.episodeGroupButtonSelected,
]}
onPress={() => setSelectedEpisodeGroup(groupIndex)}
>
<Text style={styles.episodeGroupButtonText}>
{`${groupIndex * episodeGroupSize + 1}-${Math.min(
(groupIndex + 1) * episodeGroupSize,
episodes.length
)}`}
</Text>
</TouchableOpacity>
)
)}
</View>
)}
<FlatList
data={episodes.slice(
selectedEpisodeGroup * episodeGroupSize,
(selectedEpisodeGroup + 1) * episodeGroupSize
)}
numColumns={5}
keyExtractor={(_, index) =>
`episode-${selectedEpisodeGroup * episodeGroupSize + index}`
}
renderItem={({ item, index }) => {
const absoluteIndex =
selectedEpisodeGroup * episodeGroupSize + index;
return (
<Pressable
style={({ focused }) => [
styles.episodeItem,
currentEpisodeIndex === absoluteIndex &&
styles.episodeItemSelected,
focused && styles.focusedButton,
]}
onPress={() => onSelectEpisode(absoluteIndex)}
hasTVPreferredFocus={currentEpisodeIndex === absoluteIndex}
>
<Text style={styles.episodeItemText}>
{item.title || `${absoluteIndex + 1}`}
</Text>
</Pressable>
);
}}
/>
<Pressable
style={({ focused }) => [
styles.closeButton,
focused && styles.focusedButton,
]}
onPress={onClose}
>
<Text style={{ color: "white" }}></Text>
</Pressable>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
flexDirection: "row",
justifyContent: "flex-end",
backgroundColor: "transparent",
},
modalContent: {
width: 400,
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.85)",
padding: 20,
},
modalTitle: {
color: "white",
marginBottom: 20,
textAlign: "center",
fontSize: 18,
fontWeight: "bold",
},
episodeItem: {
backgroundColor: "#333",
paddingVertical: 12,
borderRadius: 8,
margin: 4,
flex: 1,
alignItems: "center",
justifyContent: "center",
},
episodeItemSelected: {
backgroundColor: "#007bff",
},
episodeItemText: {
color: "white",
fontSize: 14,
},
episodeGroupContainer: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
marginBottom: 15,
paddingHorizontal: 10,
},
episodeGroupButton: {
backgroundColor: "#444",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 15,
margin: 5,
},
episodeGroupButtonSelected: {
backgroundColor: "#007bff",
},
episodeGroupButtonText: {
color: "white",
fontSize: 12,
},
closeButton: {
backgroundColor: "#333",
padding: 15,
borderRadius: 8,
alignItems: "center",
marginTop: 20,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
transform: [{ scale: 1.1 }],
},
});

View File

@@ -1,246 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,21 +0,0 @@
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>
);
}

View File

@@ -1,40 +0,0 @@
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,27 @@
import React from "react";
import { View, StyleSheet, ActivityIndicator } from "react-native";
interface LoadingOverlayProps {
visible: boolean;
}
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible }) => {
if (!visible) {
return null;
}
return (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#fff" />
</View>
);
};
const styles = StyleSheet.create({
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.5)",
},
});

View File

@@ -0,0 +1,52 @@
import React from "react";
import { Pressable, StyleSheet, StyleProp, ViewStyle } from "react-native";
interface MediaButtonProps {
onPress: () => void;
children: React.ReactNode;
isFocused?: boolean;
isDisabled?: boolean;
style?: StyleProp<ViewStyle>;
}
export const MediaButton: React.FC<MediaButtonProps> = ({
onPress,
children,
isFocused = false,
isDisabled = false,
style,
}) => {
return (
<Pressable
onPress={onPress}
disabled={isDisabled}
style={[
styles.mediaControlButton,
isFocused && styles.focusedButton,
isDisabled && styles.disabledButton,
style,
]}
>
{children}
</Pressable>
);
};
const styles = StyleSheet.create({
mediaControlButton: {
backgroundColor: "rgba(51, 51, 51, 0.8)",
padding: 12,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
minWidth: 80,
margin: 5,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
transform: [{ scale: 1.1 }],
},
disabledButton: {
opacity: 0.5,
},
});

View File

@@ -0,0 +1,59 @@
import React from "react";
import { View, StyleSheet, TouchableOpacity } from "react-native";
import { ThemedText } from "@/components/ThemedText";
interface NextEpisodeOverlayProps {
visible: boolean;
onCancel: () => void;
}
export const NextEpisodeOverlay: React.FC<NextEpisodeOverlayProps> = ({
visible,
onCancel,
}) => {
if (!visible) {
return null;
}
return (
<View style={styles.nextEpisodeOverlay}>
<View style={styles.nextEpisodeContent}>
<ThemedText style={styles.nextEpisodeTitle}>
...
</ThemedText>
<TouchableOpacity style={styles.nextEpisodeButton} onPress={onCancel}>
<ThemedText style={styles.nextEpisodeButtonText}></ThemedText>
</TouchableOpacity>
</View>
</View>
);
};
const styles = StyleSheet.create({
nextEpisodeOverlay: {
position: "absolute",
right: 40,
bottom: 60,
backgroundColor: "rgba(0,0,0,0.8)",
borderRadius: 8,
padding: 15,
width: 250,
},
nextEpisodeContent: {
alignItems: "center",
},
nextEpisodeTitle: {
fontSize: 16,
fontWeight: "bold",
marginBottom: 10,
},
nextEpisodeButton: {
backgroundColor: "#333",
padding: 8,
paddingHorizontal: 15,
borderRadius: 5,
},
nextEpisodeButtonText: {
fontSize: 14,
},
});

View File

@@ -1,87 +0,0 @@
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,233 @@
import React from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Pressable,
} from "react-native";
import { useRouter } from "expo-router";
import { AVPlaybackStatus } from "expo-av";
import {
ArrowLeft,
Pause,
Play,
SkipForward,
List,
ChevronsRight,
ChevronsLeft,
} from "lucide-react-native";
import { ThemedText } from "@/components/ThemedText";
import { MediaButton } from "@/components/MediaButton";
interface PlayerControlsProps {
videoTitle: string;
currentEpisodeTitle?: string;
status: AVPlaybackStatus | null;
isSeeking: boolean;
seekPosition: number;
progressPosition: number;
currentFocus: string | null;
hasNextEpisode: boolean;
onSeekStart: () => void;
onSeekMove: (event: { nativeEvent: { locationX: number } }) => void;
onSeekRelease: (event: { nativeEvent: { locationX: number } }) => void;
onSeek: (forward: boolean) => void;
onTogglePlayPause: () => void;
onPlayNextEpisode: () => void;
onShowEpisodes: () => void;
formatTime: (time: number) => string;
}
export const PlayerControls: React.FC<PlayerControlsProps> = ({
videoTitle,
currentEpisodeTitle,
status,
isSeeking,
seekPosition,
progressPosition,
currentFocus,
hasNextEpisode,
onSeekStart,
onSeekMove,
onSeekRelease,
onSeek,
onTogglePlayPause,
onPlayNextEpisode,
onShowEpisodes,
formatTime,
}) => {
const router = useRouter();
return (
<View style={styles.controlsOverlay}>
<View style={styles.topControls}>
<TouchableOpacity
style={styles.controlButton}
onPress={() => router.back()}
>
<ArrowLeft color="white" size={24} />
</TouchableOpacity>
<Text style={styles.controlTitle}>
{videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ""}
</Text>
</View>
<View style={styles.bottomControlsContainer}>
<View style={styles.progressBarContainer}>
<View style={styles.progressBarBackground} />
<View
style={[
styles.progressBarFilled,
{
width: `${
(isSeeking ? seekPosition : progressPosition) * 100
}%`,
},
]}
/>
<Pressable
style={styles.progressBarTouchable}
onPressIn={onSeekStart}
onTouchMove={onSeekMove}
onTouchEnd={onSeekRelease}
/>
</View>
<ThemedText style={{ color: "white", marginTop: 5 }}>
{status?.isLoaded
? `${formatTime(status.positionMillis)} / ${formatTime(
status.durationMillis || 0
)}`
: "00:00 / 00:00"}
</ThemedText>
<View style={styles.bottomControls}>
<MediaButton
onPress={() => onSeek(false)}
isFocused={currentFocus === "skipBack"}
>
<ChevronsLeft color="white" size={24} />
</MediaButton>
<MediaButton
onPress={onTogglePlayPause}
isFocused={currentFocus === "playPause"}
>
{status?.isLoaded && status.isPlaying ? (
<Pause color="white" size={24} />
) : (
<Play color="white" size={24} />
)}
</MediaButton>
<MediaButton
onPress={onPlayNextEpisode}
isFocused={currentFocus === "nextEpisode"}
isDisabled={!hasNextEpisode}
>
<SkipForward color={hasNextEpisode ? "white" : "#666"} size={24} />
</MediaButton>
<MediaButton
onPress={() => onSeek(true)}
isFocused={currentFocus === "skipForward"}
>
<ChevronsRight color="white" size={24} />
</MediaButton>
<MediaButton
onPress={onShowEpisodes}
isFocused={currentFocus === "episodes"}
>
<List color="white" size={24} />
</MediaButton>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
controlsOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: "space-between",
padding: 20,
},
topControls: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
controlTitle: {
color: "white",
fontSize: 16,
fontWeight: "bold",
flex: 1,
textAlign: "center",
marginHorizontal: 10,
},
bottomControlsContainer: {
width: "100%",
alignItems: "center",
},
bottomControls: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 10,
flexWrap: "wrap",
marginTop: 15,
},
progressBarContainer: {
width: "100%",
height: 8,
position: "relative",
marginTop: 10,
},
progressBarBackground: {
position: "absolute",
left: 0,
right: 0,
height: 8,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 4,
},
progressBarFilled: {
position: "absolute",
left: 0,
height: 8,
backgroundColor: "#ff0000",
borderRadius: 4,
},
progressBarTouchable: {
position: "absolute",
left: 0,
right: 0,
height: 30,
top: -10,
zIndex: 10,
},
controlButton: {
padding: 10,
flexDirection: "row",
alignItems: "center",
},
topRightContainer: {
padding: 10,
alignItems: "center",
justifyContent: "center",
minWidth: 44, // Match TouchableOpacity default size for alignment
},
resolutionText: {
color: "white",
fontSize: 16,
fontWeight: "bold",
backgroundColor: "rgba(0,0,0,0.5)",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
});

View File

@@ -1,67 +0,0 @@
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,
},
});

View File

@@ -1,14 +1,23 @@
import React, { useState, useEffect, useCallback } from "react";
import { View, Text, Image, StyleSheet, Pressable } from "react-native";
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
View,
Text,
Image,
StyleSheet,
Pressable,
TouchableOpacity,
Alert,
} 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";
import { Heart, Star, Play, Trash2 } from "lucide-react-native";
import { FavoriteManager, PlayRecordManager } from "@/services/storage";
import { API, moonTVApi } from "@/services/api";
import { ThemedText } from "@/components/ThemedText";
interface VideoCardProps {
id: string;
@@ -17,8 +26,13 @@ interface VideoCardProps {
poster: string;
year?: string;
rate?: string;
sourceName?: string;
progress?: number; // 播放进度0-1之间的小数
episodeIndex?: number; // 剧集索引
totalEpisodes?: number; // 总集数
onFocus?: () => void;
api: MoonTVAPI;
onRecordDeleted?: () => void; // 添加回调属性
api: API;
}
export default function VideoCard({
@@ -28,12 +42,18 @@ export default function VideoCard({
poster,
year,
rate,
sourceName,
progress,
episodeIndex,
totalEpisodes,
onFocus,
onRecordDeleted,
api,
}: VideoCardProps) {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const longPressTriggered = useRef(false);
const scale = useSharedValue(1);
@@ -43,19 +63,23 @@ export default function VideoCard({
};
});
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 },
});
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
// 如果有播放进度,直接转到播放页面
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex },
});
} else {
router.push({
pathname: "/detail",
params: { source, q: title },
});
}
};
const handleFocus = useCallback(() => {
@@ -69,22 +93,57 @@ export default function VideoCard({
scale.value = withSpring(1.0);
}, [scale]);
const handleToggleFavorite = async () => {
const newFavState = await FavoriteManager.toggle(source, id, {
title,
poster,
source_name: source,
});
setIsFavorited(newFavState);
const handleLongPress = () => {
// Only allow long press for items with progress (play records)
if (progress === undefined) return;
longPressTriggered.current = true;
// Show confirmation dialog to delete play record
Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
{
text: "取消",
style: "cancel",
},
{
text: "删除",
style: "destructive",
onPress: async () => {
try {
// Delete from local storage
await PlayRecordManager.remove(source, id);
// Call the onRecordDeleted callback
if (onRecordDeleted) {
onRecordDeleted();
}
// 如果没有回调函数,则使用导航刷新作为备选方案
else if (router.canGoBack()) {
router.replace("/");
}
} catch (error) {
console.error("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
},
},
]);
};
// 是否是继续观看的视频
const isContinueWatching =
progress !== undefined && progress > 0 && progress < 1;
return (
<Animated.View style={[styles.wrapper, animatedStyle]}>
<Pressable
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
activeOpacity={1}
delayLongPress={1000}
>
<View style={styles.card}>
<Image
@@ -93,32 +152,58 @@ export default function VideoCard({
/>
{isFocused && (
<View style={styles.overlay}>
<Pressable
onPress={handleToggleFavorite}
style={styles.favButton}
>
<Heart
size={24}
color={isFavorited ? "red" : "white"}
fill={isFavorited ? "red" : "transparent"}
/>
</Pressable>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<ThemedText style={styles.continueWatchingText}>
</ThemedText>
</View>
)}
</View>
)}
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View
style={[
styles.progressBar,
{ width: `${(progress || 0) * 100}%` },
]}
/>
</View>
)}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<Text style={styles.ratingText}>{rate}</Text>
<ThemedText style={styles.ratingText}>{rate}</ThemedText>
</View>
)}
{year && (
<View style={styles.yearBadge}>
<Text style={styles.badgeText}>{year}</Text>
</View>
)}
{sourceName && (
<View style={styles.sourceNameBadge}>
<Text style={styles.badgeText}>{sourceName}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{year && <Text style={styles.year}>{year}</Text>}
<ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && !isFocused && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}>
{episodeIndex! + 1} {" "}
{Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</Pressable>
</TouchableOpacity>
</Animated.View>
);
}
@@ -150,6 +235,16 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
},
buttonRow: {
position: "absolute",
top: 8,
left: 8,
flexDirection: "row",
gap: 8,
},
iconButton: {
padding: 4,
},
favButton: {
position: "absolute",
top: 8,
@@ -175,7 +270,14 @@ const styles = StyleSheet.create({
infoContainer: {
width: CARD_WIDTH,
marginTop: 8,
alignItems: "center",
alignItems: "flex-start", // Align items to the start
marginBottom: 16,
paddingHorizontal: 4, // Add some padding
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
title: {
color: "white",
@@ -183,9 +285,57 @@ const styles = StyleSheet.create({
fontWeight: "bold",
textAlign: "center",
},
year: {
color: "#aaa",
yearBadge: {
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
sourceNameBadge: {
position: "absolute",
top: 8,
left: 8,
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
badgeText: {
color: "white",
fontSize: 12,
fontWeight: "bold",
},
progressContainer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
progressBar: {
height: 3,
backgroundColor: "#ff0000",
},
continueWatchingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(255, 0, 0, 0.8)",
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 5,
},
continueWatchingText: {
color: "white",
marginLeft: 5,
fontSize: 12,
fontWeight: "bold",
},
continueLabel: {
color: "#ff5252",
fontSize: 12,
textAlign: "center",
},
});