38 Commits

Author SHA1 Message Date
zimplexing
10bfbbbf8e fix: ensure data is refreshed for all categories by calling fetchInitialData unconditionally 2025-07-29 19:15:57 +08:00
zimplexing
187a753735 feat: Enhance UI with fade animations and implement data caching in home store 2025-07-28 10:28:02 +08:00
Xin
8cda0d7a82 Update README.md 2025-07-26 15:06:10 +08:00
zimplexing
b2de622a40 feat: Update disclaimer 2025-07-26 15:05:17 +08:00
Xin
2988dad829 Merge pull request #96 from zimplexing/v1.2.9
fix: UI issue
2025-07-26 14:59:33 +08:00
zimplexing
0f8cc49019 fix: add disclaimer alert after successful login and update error message 2025-07-26 14:57:51 +08:00
zimplexing
8ea588617d fix: update placeholder and error message 2025-07-26 14:50:53 +08:00
zimplexing
89b5f1df9d chore: delete docs 2025-07-26 12:54:46 +08:00
zimplexing
2ba7782f5d chore: delete backend 2025-07-26 12:53:47 +08:00
zimplexing
48b983c2b4 Merge remote-tracking branch 'origin/master' into v1.2.9 2025-07-26 12:53:11 +08:00
Xin
0c3b8f753e Update README.md 2025-07-26 12:46:25 +08:00
Xin
76bbbb9439 Update README.md 2025-07-26 12:32:19 +08:00
zimplexing
e5a40da8ad chore: bump version to 1.2.9 in package.json 2025-07-25 18:25:39 +08:00
zimplexing
80cb5310c4 fix: UI issue 2025-07-25 18:24:42 +08:00
Xin
928432e81c Merge pull request #94 from zimplexing/v1.2.8
refactor: Update scroll experience
2025-07-25 16:32:47 +08:00
zimplexing
d1f0a2eb87 feat: Optimize availability checking of api addresses 2025-07-25 16:32:15 +08:00
zimplexing
62c03beb5e fix: search input focus issue 2025-07-25 16:00:11 +08:00
zimplexing
5992a89db4 feat: Update scroll experience 2025-07-25 15:39:23 +08:00
zimplexing
c9587d7070 chore: bump version to 1.2.8 in package.json 2025-07-25 14:15:18 +08:00
zimplexing
75d7f675f7 fix: reload video with new source while preserving playback position 2025-07-25 13:58:26 +08:00
zimplexing
9cbd23c36a refactor: improve focus management and input handling in LoginModal 2025-07-25 13:34:11 +08:00
zimplexing
3fa2eb3159 feat: add useKeepAwake to LivePlayer and update version to 1.2.7 2025-07-21 19:03:12 +08:00
zimplexing
e4e4417ef6 feat: enhance LivePlayer messages with localized text and improve M3U parsing logic 2025-07-21 14:06:44 +08:00
Xin
64cdcb78b6 Merge pull request #73 from Moon3r/fix-m3u-without-extinf
Fix fetch m3u failed when m3u file without extinf.
2025-07-21 11:04:35 +08:00
Moon3r
809422f702 Fix fetch m3u failed when m3u file without extinf. 2025-07-21 10:16:42 +08:00
Xin
1c9b3b2553 Update README.md 2025-07-21 09:16:01 +08:00
Xin
e02b3c512f Update README.md 2025-07-21 09:14:50 +08:00
zimplexing
fe05525805 fix: add padding to badge text for improved spacing 2025-07-18 23:00:42 +08:00
zimplexing
1be777825b fix: update VideoCard styles for improved visual consistency and accessibility 2025-07-18 22:57:56 +08:00
zimplexing
813ca40576 chore: bump version to 1.2.5 in package.json 2025-07-18 22:21:53 +08:00
zimplexing
4c633febdc feat: update color scheme and styles across components for improved UI consistency 2025-07-18 22:21:33 +08:00
zimplexing
2fd30c8fd7 chore: bump version to 1.2.4 in package.json 2025-07-18 19:59:42 +08:00
zimplexing
f09f103d59 feat: add favorites screen and integrate it into navigation; enhance detail screen with favorite toggle functionality 2025-07-18 19:57:39 +08:00
zimplexing
828a0b3d72 chore: bump version to 1.2.3 in package.json 2025-07-18 19:09:29 +08:00
zimplexing
e8a1ea2717 feat: update PlayScreen and VideoLoadingAnimation styles, and log play record saving in playerStore 2025-07-18 19:08:49 +08:00
zimplexing
bd7087264d feat: add VideoLoadingAnimation component and integrate it into detail, play, and search screens for improved loading experience 2025-07-18 17:15:24 +08:00
zimplexing
990745eba9 feat: modify LoginModal to conditionally handle visibility based on pathname, preventing display on settings page 2025-07-18 12:19:29 +08:00
zimplexing
cab3e2ed12 feat: refactor playerStore to improve loadVideo and _savePlayRecord methods with enhanced options and throttling logic 2025-07-18 11:26:06 +08:00
65 changed files with 1119 additions and 4087 deletions

View File

@@ -1,13 +1,12 @@
# OrionTV 📺
一个基于 React Native TVOS 和 Expo 构建的跨平台电视应用,旨在提供流畅的视频观看体验。项目包含一个用于数据服务的 Express 后端。
一个基于 React Native TVOS 和 Expo 构建的播放器,旨在提供流畅的视频观看体验。
## ✨ 功能特性
- **跨平台支持**: 同时支持 Apple TV 和 Android TV。
- **框架跨平台支持**: 同时支持构建 Apple TV 和 Android TV。
- **现代化前端**: 使用 Expo、React Native TVOS 和 TypeScript 构建,性能卓越。
- **Expo Router**: 基于文件系统的路由,使导航逻辑清晰简单。
- **后端服务**: 配套 Express 后端,用于处理数据获取、搜索和详情展示。
- **TV 优化的 UI**: 专为电视遥控器交互设计的用户界面。
## 🛠️ 技术栈
@@ -52,10 +51,8 @@
接下来,在项目根目录运行前端应用:
```sh
# (如果还在 backend 目录) 返回根目录
cd ..
# 安装前端依赖
# 安装依赖
yarn
# [首次运行或依赖更新后] 生成原生项目文件
@@ -69,18 +66,10 @@ yarn ios-tv
yarn android-tv
```
## 部署
## 使用
- 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 部署使用api 地址填部MoonTV署后的访问地址
- 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 使用
- **注意:** 地址后面不要带 `/` ,不要遗漏 `http://` 或者 `https://`
- 如果不想依赖 MoonTV可以使用 1.1.x 版本。
## 其他
- 最低版本是 android 6.0,可用,但是不推荐
- 如果使用 https 的后端接口无法访问,在确认服务没有问题的情况下,请检查 https 的 TLS 协议Android 10 之后版本才支持 TLS1.3
## 📜 主要脚本
@@ -89,14 +78,7 @@ yarn android-tv
- `yarn ios-tv`: 在 Apple TV 上构建并运行应用。
- `yarn android-tv`: 在 Android TV 上构建并运行应用。
- `yarn prebuild-tv`: 为 TV 构建生成原生项目文件。
- `yarn lint`: 检查代码风格
## 📸 应用截图
![首页界面](screenshot/image.png)
![视频播放](screenshot/image2.png)
![搜索界面](screenshot/image3.png)
![详情页面](screenshot/image1.png)
- `yarn lint`: 检查代码风格
## 📝 License
@@ -122,4 +104,3 @@ OrionTV 仅作为视频搜索工具,不存储、上传或分发任何视频内
感谢以下项目提供 API Key 的赞助
- [gpt-load](https://github.com/tbphp/gpt-load) - 一个高性能的 OpenAI 格式 API 多密钥轮询代理服务器,支持负载均衡,使用 Go 语言开发
- [one-balance](https://github.com/glidea/one-balance) - Make ai KEY rotation SMARTER and more SECURE

View File

@@ -38,6 +38,7 @@
"android": {
"package": "com.oriontv",
"usesCleartextTraffic": true,
"hardwareAcceleration": true,
"networkSecurityConfig": "@xml/network_security_config",
"icon": "./assets/images/icon.png",
"permissions": [

View File

@@ -63,7 +63,7 @@ export default function RootLayout() {
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="live" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
{/* <Stack.Screen name="favorites" options={{ headerShown: false }} /> */}
<Stack.Screen name="favorites" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<Toast />

View File

@@ -4,6 +4,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { StyledButton } from "@/components/StyledButton";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import useDetailStore from "@/stores/detailStore";
import { FontAwesome } from "@expo/vector-icons";
@@ -49,11 +50,7 @@ export default function DetailScreen() {
};
if (loading) {
return (
<ThemedView style={styles.centered}>
<ActivityIndicator size="large" />
</ThemedView>
);
return <VideoLoadingAnimation showProgressBar={false} />;
}
if (error) {
@@ -80,17 +77,23 @@ export default function DetailScreen() {
<View style={styles.topContainer}>
<Image source={{ uri: detail.poster }} style={styles.poster} />
<View style={styles.infoContainer}>
<ThemedText style={styles.title} numberOfLines={1}>
{detail.title}
</ThemedText>
<View style={styles.titleContainer}>
<ThemedText style={styles.title} numberOfLines={1} ellipsizeMode="tail">
{detail.title}
</ThemedText>
<StyledButton onPress={toggleFavorite} variant="ghost" style={styles.favoriteButton}>
<FontAwesome
name={isFavorited ? "heart" : "heart-o"}
size={24}
color={isFavorited ? "#feff5f" : "#ccc"}
/>
</StyledButton>
</View>
<View style={styles.metaContainer}>
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
</View>
{/* <Pressable onPress={toggleFavorite} style={styles.favoriteButton}>
<FontAwesome name={isFavorited ? "star" : "star-o"} size={24} color={isFavorited ? "#FFD700" : "#ccc"} />
<ThemedText style={styles.favoriteButtonText}>{isFavorited ? "已收藏" : "收藏"}</ThemedText>
</Pressable> */}
<ScrollView style={styles.descriptionScrollView}>
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
</ScrollView>
@@ -104,29 +107,32 @@ export default function DetailScreen() {
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
</View>
<View style={styles.sourceList}>
{searchResults.map((item, index) => (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={detail?.source === item.source}
style={styles.sourceButton}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: "#28a745" }]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
))}
{searchResults.map((item, index) => {
const isSelected = detail?.source === item.source;
return (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={isSelected}
style={styles.sourceButton}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={[styles.badge, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: "#666" }, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
);
})}
</View>
</View>
<View style={styles.episodesContainer}>
@@ -170,11 +176,15 @@ const styles = StyleSheet.create({
marginLeft: 20,
justifyContent: "flex-start",
},
titleContainer: {
flexDirection: "row",
alignItems: "center",
},
title: {
paddingTop: 16,
fontSize: 28,
fontWeight: "bold",
marginBottom: 10,
paddingTop: 20,
flexShrink: 1,
},
metaContainer: {
flexDirection: "row",
@@ -194,13 +204,9 @@ const styles = StyleSheet.create({
lineHeight: 22,
},
favoriteButton: {
flexDirection: "row",
alignItems: "center",
marginTop: 10,
padding: 10,
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderRadius: 5,
alignSelf: "flex-start",
marginLeft: 10,
backgroundColor: "transparent",
},
favoriteButtonText: {
marginLeft: 8,
@@ -233,16 +239,23 @@ const styles = StyleSheet.create({
fontSize: 16,
},
badge: {
backgroundColor: "red",
backgroundColor: "#666",
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: 8,
},
badgeText: {
color: "white",
color: "#fff",
fontSize: 12,
fontWeight: "bold",
paddingBottom: 2.5,
},
selectedBadge: {
backgroundColor: "#4c4c4c",
},
selectedbadgeText: {
color: "#333",
},
episodesContainer: {
marginTop: 20,

View File

@@ -1,74 +1,49 @@
import React, { useEffect } from "react";
import { View, FlatList, StyleSheet, ActivityIndicator, Image, Pressable } from "react-native";
import { useRouter } from "expo-router";
import { View, StyleSheet, ActivityIndicator } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import useFavoritesStore from "@/stores/favoritesStore";
import { Favorite } from "@/services/storage";
import VideoCard from "@/components/VideoCard.tv";
import { api } from "@/services/api";
import CustomScrollView from "@/components/CustomScrollView";
export default function FavoritesScreen() {
const router = useRouter();
const { favorites, loading, error, fetchFavorites } = useFavoritesStore();
useEffect(() => {
fetchFavorites();
}, [fetchFavorites]);
const handlePress = (favorite: Favorite & { key: string }) => {
const [source, id] = favorite.key.split("+");
router.push({
pathname: "/detail",
params: { q: favorite.title, source, id },
});
const renderItem = ({ item }: { item: Favorite & { key: string }; index: number }) => {
const [source, id] = item.key.split("+");
return (
<VideoCard
id={id}
source={source}
title={item.title}
sourceName={item.source_name}
poster={item.cover}
year={item.year}
api={api}
episodeIndex={1}
progress={0}
/>
);
};
if (loading) {
return (
<ThemedView style={styles.centered}>
<ActivityIndicator size="large" />
</ThemedView>
);
}
if (error) {
return (
<ThemedView style={styles.centered}>
<ThemedText type="subtitle">{error}</ThemedText>
</ThemedView>
);
}
if (favorites.length === 0) {
return (
<ThemedView style={styles.centered}>
<ThemedText type="subtitle"></ThemedText>
</ThemedView>
);
}
const renderItem = ({ item }: { item: Favorite & { key: string } }) => (
<Pressable onPress={() => handlePress(item)} style={styles.itemContainer}>
<Image source={{ uri: item.poster }} style={styles.poster} />
<View style={styles.infoContainer}>
<ThemedText style={styles.title} numberOfLines={1}>
{item.title}
</ThemedText>
<ThemedText style={styles.year}>{item.year}</ThemedText>
</View>
</Pressable>
);
return (
<ThemedView style={styles.container}>
<View style={styles.headerContainer}>
<ThemedText style={styles.headerTitle}></ThemedText>
</View>
<FlatList
<CustomScrollView
data={favorites}
renderItem={renderItem}
keyExtractor={(item) => item.key}
numColumns={3}
contentContainerStyle={styles.list}
numColumns={5}
loading={loading}
error={error}
emptyMessage="暂无收藏"
/>
</ThemedView>
);
@@ -99,26 +74,4 @@ const styles = StyleSheet.create({
list: {
padding: 10,
},
itemContainer: {
flex: 1,
margin: 10,
alignItems: "center",
},
poster: {
width: 120,
height: 180,
borderRadius: 8,
},
infoContainer: {
marginTop: 8,
alignItems: "center",
},
title: {
fontSize: 16,
fontWeight: "bold",
},
year: {
fontSize: 14,
color: "#888",
},
});

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useCallback, useRef, useState } from "react";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions } from "react-native";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions, Animated } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { api } from "@/services/api";
@@ -9,16 +9,19 @@ import { Search, Settings, LogOut, Heart } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton";
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
import useAuthStore from "@/stores/authStore";
import CustomScrollView from "@/components/CustomScrollView";
const NUM_COLUMNS = 5;
const { width } = Dimensions.get("window");
const ITEM_WIDTH = width / NUM_COLUMNS - 24;
// Threshold for triggering load more data (in pixels)
const LOAD_MORE_THRESHOLD = 200;
export default function HomeScreen() {
const router = useRouter();
const colorScheme = "dark";
const flatListRef = useRef<FlatList>(null);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const fadeAnim = useRef(new Animated.Value(0)).current;
const {
categories,
@@ -43,7 +46,6 @@ export default function HomeScreen() {
useEffect(() => {
if (selectedCategory && !selectedCategory.tags) {
fetchInitialData();
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
} else if (selectedCategory?.tags && !selectedCategory.tag) {
// Category with tags selected, but no specific tag yet. Select the first one.
const defaultTag = selectedCategory.tags[0];
@@ -55,10 +57,21 @@ export default function HomeScreen() {
useEffect(() => {
if (selectedCategory && selectedCategory.tag) {
fetchInitialData();
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
}
}, [fetchInitialData, selectedCategory, selectedCategory.tag]);
useEffect(() => {
if (!loading && contentData.length > 0) {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
} else if (loading) {
fadeAnim.setValue(0);
}
}, [loading, contentData.length, fadeAnim]);
const handleCategorySelect = (category: Category) => {
setSelectedTag(null);
selectCategory(category);
@@ -86,7 +99,7 @@ export default function HomeScreen() {
);
};
const renderContentItem = ({ item }: { item: RowItem }) => (
const renderContentItem = ({ item, index }: { item: RowItem; index: number }) => (
<View style={styles.itemContainer}>
<VideoCard
id={item.id}
@@ -124,9 +137,9 @@ export default function HomeScreen() {
</Pressable>
</View>
<View style={styles.rightHeaderButtons}>
{/* <StyledButton style={styles.searchButton} onPress={() => router.push("/favorites")} variant="ghost">
<StyledButton style={styles.searchButton} onPress={() => router.push("/favorites")} variant="ghost">
<Heart color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton> */}
</StyledButton>
<StyledButton
style={styles.searchButton}
onPress={() => router.push({ pathname: "/search" })}
@@ -196,22 +209,20 @@ export default function HomeScreen() {
</ThemedText>
</View>
) : (
<FlatList
ref={flatListRef}
data={contentData}
renderItem={renderContentItem}
keyExtractor={(item, index) => `${item.source}-${item.id}-${index}`}
numColumns={NUM_COLUMNS}
contentContainerStyle={styles.listContent}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
ListEmptyComponent={
<View style={styles.centerContainer}>
<ThemedText>{selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"}</ThemedText>
</View>
}
/>
<Animated.View style={[styles.contentContainer, { opacity: fadeAnim }]}>
<CustomScrollView
data={contentData}
renderItem={renderContentItem}
numColumns={NUM_COLUMNS}
loading={loading}
loadingMore={loadingMore}
error={error}
onEndReached={loadMoreData}
loadMoreThreshold={LOAD_MORE_THRESHOLD}
emptyMessage={selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"}
ListFooterComponent={renderFooter}
/>
</Animated.View>
)}
</ThemedView>
);
@@ -270,9 +281,11 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingBottom: 20,
},
contentContainer: {
flex: 1,
},
itemContainer: {
margin: 8,
width: ITEM_WIDTH,
alignItems: "center",
},
});

View File

@@ -106,7 +106,7 @@ export default function LiveScreen() {
<View style={styles.groupColumn}>
<FlatList
data={channelGroups}
keyExtractor={(item) => item}
keyExtractor={(item, index) => `group-${item}-${index}`}
renderItem={({ item }) => (
<StyledButton
text={item}
@@ -124,7 +124,7 @@ export default function LiveScreen() {
) : (
<FlatList
data={groupedChannels[selectedGroup] || []}
keyExtractor={(item) => item.id}
keyExtractor={(item, index) => `${item.id}-${item.group}-${index}`}
renderItem={({ item }) => (
<StyledButton
text={item.name || "Unknown Channel"}
@@ -190,6 +190,8 @@ const styles = StyleSheet.create({
paddingVertical: 8,
paddingHorizontal: 4,
marginVertical: 4,
paddingLeft: 10,
paddingRight: 10,
},
groupButtonText: {
fontSize: 13,
@@ -198,6 +200,8 @@ const styles = StyleSheet.create({
paddingVertical: 6,
paddingHorizontal: 4,
marginVertical: 3,
paddingLeft: 16,
paddingRight: 16,
},
channelItemText: {
fontSize: 12,

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react";
import { StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler, AppState, AppStateStatus } from "react-native";
import { StyleSheet, TouchableOpacity, BackHandler, AppState, AppStateStatus, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
@@ -8,8 +8,8 @@ import { PlayerControls } from "@/components/PlayerControls";
import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
import { SourceSelectionModal } from "@/components/SourceSelectionModal";
import { SeekingBar } from "@/components/SeekingBar";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import { LoadingOverlay } from "@/components/LoadingOverlay";
// import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
import Toast from "react-native-toast-message";
@@ -42,13 +42,13 @@ export default function PlayScreen() {
const {
isLoading,
showControls,
showNextEpisodeOverlay,
// showNextEpisodeOverlay,
initialPosition,
introEndTime,
setVideoRef,
handlePlaybackStatusUpdate,
setShowControls,
setShowNextEpisodeOverlay,
// setShowNextEpisodeOverlay,
reset,
loadVideo,
} = usePlayerStore();
@@ -116,11 +116,7 @@ export default function PlayScreen() {
}, [isLoading]);
if (!detail) {
return (
<ThemedView style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#fff" />
</ThemedView>
);
return <VideoLoadingAnimation showProgressBar />;
}
return (
@@ -130,7 +126,6 @@ export default function PlayScreen() {
ref={videoRef}
style={styles.videoPlayer}
source={{ uri: currentEpisode?.url || "" }}
usePoster
posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
@@ -150,9 +145,13 @@ export default function PlayScreen() {
<SeekingBar />
<LoadingOverlay visible={isLoading} />
{isLoading && (
<View style={styles.videoContainer}>
<VideoLoadingAnimation showProgressBar />
</View>
)}
<NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} />
{/* <NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} /> */}
</TouchableOpacity>
<EpisodeSelectionModal />

View File

@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect } from "react";
import { View, TextInput, StyleSheet, FlatList, ActivityIndicator, Alert, Keyboard } from "react-native";
import { View, TextInput, StyleSheet, Alert, Keyboard, TouchableOpacity, Pressable } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import VideoCard from "@/components/VideoCard.tv";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import { api, SearchResult } from "@/services/api";
import { Search, QrCode } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton";
@@ -10,6 +11,8 @@ import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { RemoteControlModal } from "@/components/RemoteControlModal";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRouter } from "expo-router";
import { Colors } from "@/constants/Colors";
import CustomScrollView from "@/components/CustomScrollView";
export default function SearchScreen() {
const [keyword, setKeyword] = useState("");
@@ -33,13 +36,13 @@ export default function SearchScreen() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessage]);
useEffect(() => {
// Focus the text input when the screen loads
const timer = setTimeout(() => {
textInputRef.current?.focus();
}, 200);
return () => clearTimeout(timer);
}, []);
// useEffect(() => {
// // Focus the text input when the screen loads
// const timer = setTimeout(() => {
// textInputRef.current?.focus();
// }, 200);
// return () => clearTimeout(timer);
// }, []);
const handleSearch = async (searchText?: string) => {
const term = typeof searchText === "string" ? searchText : keyword;
@@ -78,7 +81,7 @@ export default function SearchScreen() {
showRemoteModal();
};
const renderItem = ({ item }: { item: SearchResult }) => (
const renderItem = ({ item, index }: { item: SearchResult; index: number }) => (
<VideoCard
id={item.id.toString()}
source={item.source}
@@ -93,25 +96,36 @@ export default function SearchScreen() {
return (
<ThemedView style={styles.container}>
<View style={styles.searchContainer}>
<TextInput
ref={textInputRef}
<TouchableOpacity
activeOpacity={1}
style={[
styles.input,
{
backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0",
color: colorScheme === "dark" ? "white" : "black",
borderColor: isInputFocused ? "#007bff" : "transparent",
borderColor: isInputFocused ? Colors.dark.primary : "transparent",
borderWidth: 2,
},
]}
placeholder="搜索电影、剧集..."
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
value={keyword}
onChangeText={setKeyword}
onPress={() => textInputRef.current?.focus()}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onSubmitEditing={onSearchPress}
returnKeyType="search"
/>
>
<TextInput
ref={textInputRef}
style={[
styles.input,
{
color: colorScheme === "dark" ? "white" : "black",
},
]}
placeholder="搜索电影、剧集..."
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={onSearchPress}
returnKeyType="search"
/>
</TouchableOpacity>
<StyledButton style={styles.searchButton} onPress={onSearchPress}>
<Search size={24} color={colorScheme === "dark" ? "white" : "black"} />
</StyledButton>
@@ -121,25 +135,19 @@ export default function SearchScreen() {
</View>
{loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" />
</View>
<VideoLoadingAnimation showProgressBar={false} />
) : error ? (
<View style={styles.centerContainer}>
<ThemedText style={styles.errorText}>{error}</ThemedText>
</View>
) : (
<FlatList
<CustomScrollView
data={results}
renderItem={renderItem}
keyExtractor={(item, index) => `${item.id}-${item.source}-${index}`}
numColumns={5} // Adjust based on your card size and desired layout
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.centerContainer}>
<ThemedText></ThemedText>
</View>
}
numColumns={5}
loading={loading}
error={error}
emptyMessage="输入关键词开始搜索"
/>
)}
<RemoteControlModal />

View File

@@ -6,18 +6,17 @@ import { ThemedView } from "@/components/ThemedView";
import { StyledButton } from "@/components/StyledButton";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useSettingsStore } from "@/stores/settingsStore";
import useAuthStore from "@/stores/authStore";
// import useAuthStore from "@/stores/authStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { APIConfigSection } from "@/components/settings/APIConfigSection";
import { LiveStreamSection } from "@/components/settings/LiveStreamSection";
import { RemoteInputSection } from "@/components/settings/RemoteInputSection";
import { VideoSourceSection } from "@/components/settings/VideoSourceSection";
// import { VideoSourceSection } from "@/components/settings/VideoSourceSection";
import Toast from "react-native-toast-message";
export default function SettingsScreen() {
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
const { lastMessage } = useRemoteControlStore();
const { isLoggedIn, logout } = useAuthStore();
const backgroundColor = useThemeColor({}, "background");
const [hasChanges, setHasChanges] = useState(false);
@@ -111,18 +110,18 @@ export default function SettingsScreen() {
),
key: "livestream",
},
{
component: (
<VideoSourceSection
onChanged={markAsChanged}
onFocus={() => {
setCurrentFocusIndex(3);
setCurrentSection("videoSource");
}}
/>
),
key: "videoSource",
},
// {
// component: (
// <VideoSourceSection
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(3);
// setCurrentSection("videoSource");
// }}
// />
// ),
// key: "videoSource",
// },
];
// TV遥控器事件处理

View File

@@ -1,5 +0,0 @@
# The port the backend server will run on
PORT=3001
# Optional: The password for the login endpoint. If not provided, login is disabled.
PASSWORD=

View File

@@ -1,5 +0,0 @@
# The port the backend server will run on
PORT=3001
# Optional: The password for the login endpoint. If not provided, login is disabled.
PASSWORD=

View File

@@ -1,38 +0,0 @@
# --- Build Stage ---
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package.json and yarn.lock first to leverage Docker cache
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Copy the rest of the source code
COPY . .
# Compile TypeScript to JavaScript
RUN yarn build
# Prune development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline
# --- Production Stage ---
FROM node:18-alpine
WORKDIR /app
# Copy production dependencies and compiled code from the builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Copy config.json from the project root relative to the Docker build context
# IMPORTANT: When building, run `docker build -f backend/Dockerfile .` from the project root.
COPY src/config/config.json dist/config/
# Expose the port the app runs on
EXPOSE 3001
# The command to run the application
# You can override the port using -e PORT=... in `docker run`
CMD [ "node", "dist/index.docker.js" ]

View File

@@ -1,26 +0,0 @@
{
"name": "OrionTV-proxy",
"version": "1.0.1",
"description": "Backend service for MyTV application",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"dev": "ts-node-dev --respawn --transpile-only src/index.docker.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}

View File

@@ -1,81 +0,0 @@
{
"cache_time": 7200,
"api_site": {
"dyttzy": {
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
"name": "电影天堂资源"
},
"ruyi": {
"api": "https://cj.rycjapi.com/api.php/provide/vod",
"name": "如意资源"
},
"mozhua": {
"api": "https://mozhuazy.com/api.php/provide/vod",
"name": "魔爪资源"
},
"heimuer": {
"api": "https://json.heimuer.xyz/api.php/provide/vod",
"name": "黑木耳"
},
"bfzy": {
"api": "https://bfzyapi.com/api.php/provide/vod",
"name": "暴风资源"
},
"tyyszy": {
"api": "https://tyyszy.com/api.php/provide/vod",
"name": "天涯资源"
},
"ffzy": {
"api": "http://ffzy5.tv/api.php/provide/vod",
"name": "非凡影视"
},
"zy360": {
"api": "https://360zy.com/api.php/provide/vod",
"name": "360资源"
},
"iqiyi": {
"api": "https://www.iqiyizyapi.com/api.php/provide/vod",
"name": "iqiyi资源"
},
"wolong": {
"api": "https://wolongzyw.com/api.php/provide/vod",
"name": "卧龙资源"
},
"hwba": {
"api": "https://cjhwba.com/api.php/provide/vod",
"name": "华为吧资源"
},
"jisu": {
"api": "https://jszyapi.com/api.php/provide/vod",
"name": "极速资源"
},
"dbzy": {
"api": "https://dbzy.tv/api.php/provide/vod",
"name": "豆瓣资源"
},
"mdzy": {
"api": "https://www.mdzyapi.com/api.php/provide/vod",
"name": "魔都资源"
},
"zuid": {
"api": "https://api.zuidapi.com/api.php/provide/vod",
"name": "最大资源"
},
"yinghua": {
"api": "https://m3u8.apiyhzy.com/api.php/provide/vod",
"name": "樱花资源"
},
"wujin": {
"api": "https://api.wujinapi.me/api.php/provide/vod",
"name": "无尽资源"
},
"wwzy": {
"api": "https://wwzy.tv/api.php/provide/vod",
"name": "旺旺短剧"
},
"ikun": {
"api": "https://ikunzyapi.com/api.php/provide/vod",
"name": "iKun资源"
}
}
}

View File

@@ -1,80 +0,0 @@
import fs from "fs";
import path from "path";
export interface ApiSite {
key: string;
api: string;
name: string;
detail?: string;
}
export interface StorageConfig {
type: "localstorage" | "database";
database?: {
host?: string;
port?: number;
username?: string;
password?: string;
database?: string;
};
}
export interface Config {
cache_time?: number;
api_site: {
[key: string]: ApiSite;
};
storage?: StorageConfig;
}
export const API_CONFIG = {
search: {
path: "?ac=videolist&wd=",
pagePath: "?ac=videolist&wd={query}&pg={page}",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
Accept: "application/json",
},
},
detail: {
path: "?ac=videolist&ids=",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
Accept: "application/json",
},
},
};
// Adjust path to read from project root, not from `backend/`
const configPath = path.join(__dirname, "config.json");
let cachedConfig: Config;
try {
cachedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as Config;
} catch (error) {
console.info(`Error reading or parsing config.json at ${configPath}`, error);
// Provide a default fallback config to prevent crashes
cachedConfig = {
api_site: {},
cache_time: 300,
};
}
export function getConfig(): Config {
return cachedConfig;
}
export function getCacheTime(): number {
const config = getConfig();
return config.cache_time || 300; // 默认5分钟缓存
}
export function getApiSites(): ApiSite[] {
const config = getConfig();
return Object.entries(config.api_site).map(([key, site]) => ({
...site,
key,
}));
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,28 +0,0 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app: Express = express();
const port = process.env.PORT || 3001;
// Middlewares
app.use(cors());
app.use(express.json());
// Health check route
app.get("/", (req: Request, res: Response) => {
res.send("MyTV Backend Service is running!");
});
import apiRouter from "./routes";
// API routes
app.use("/api", apiRouter);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
export default app;

View File

@@ -1,24 +0,0 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app: Express = express();
const port = process.env.PORT || 3001;
// Middlewares
app.use(cors());
app.use(express.json());
// Health check route
app.get("/", (req: Request, res: Response) => {
res.send("MyTV Backend Service is running!");
});
import apiRouter from "./routes";
// API routes
app.use("/api", apiRouter);
export default app;

View File

@@ -1,156 +0,0 @@
import { Router, Request, Response } from "express";
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from "../config";
import { VideoDetail } from "../types";
import { cleanHtmlTags } from "../utils";
const router = Router();
// Match m3u8 links
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
async function handleSpecialSourceDetail(id: string, apiSite: ApiSite): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === "ffzy") {
const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
matches = Array.from(new Set(matches)).map((link: string) => {
link = link.substring(1);
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : "";
const descMatch = html.match(/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : "";
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : "";
return {
id,
title: titleText,
poster: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
};
}
async function getDetailFromApi(apiSite: ApiSite, id: string): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
throw new Error("获取到的详情内容无效");
}
const videoDetail = data.list[0];
let episodes: string[] = [];
if (videoDetail.vod_play_url) {
const playSources = videoDetail.vod_play_url.split("$$$");
if (playSources.length > 0) {
const mainSource = playSources[0];
const episodeList = mainSource.split("#");
episodes = episodeList
.map((ep: string) => {
const parts = ep.split("$");
return parts.length > 1 ? parts[1] : "";
})
.filter((url: string) => url && (url.startsWith("http://") || url.startsWith("https://")));
}
}
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ""));
}
return {
id,
title: videoDetail.vod_name,
poster: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year?.match(/\d{4}/)?.[0] || "",
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
};
}
async function getVideoDetail(id: string, sourceCode: string): Promise<VideoDetail> {
if (!id) {
throw new Error("缺少视频ID参数");
}
if (!/^[\w-]+$/.test(id)) {
throw new Error("无效的视频ID格式");
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
throw new Error("无效的API来源");
}
if (apiSite.detail) {
return handleSpecialSourceDetail(id, apiSite);
}
return getDetailFromApi(apiSite, id);
}
router.get("/", async (req: Request, res: Response) => {
const id = req.query.id as string;
const sourceCode = req.query.source as string;
if (!id || !sourceCode) {
return res.status(400).json({ error: "缺少必要参数" });
}
try {
const result = await getVideoDetail(id, sourceCode);
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;

View File

@@ -1,161 +0,0 @@
import { Router, Request, Response } from "express";
import { getCacheTime } from "../config";
const router = Router();
// --- Interfaces ---
interface DoubanItem {
title: string;
poster: string;
rate: string;
}
interface DoubanResponse {
code: number;
message: string;
list: DoubanItem[];
}
interface DoubanApiResponse {
subjects: Array<{
title: string;
cover: string;
rate: string;
}>;
}
// --- Helper Functions ---
async function fetchDoubanData(url: string): Promise<DoubanApiResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const fetchOptions = {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Referer: "https://movie.douban.com/",
Accept: "application/json, text/plain, */*",
},
};
try {
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
async function handleTop250(pageStart: number, res: Response) {
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const fetchOptions = {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Referer: "https://movie.douban.com/",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
},
};
try {
const fetchResponse = await fetch(target, fetchOptions);
clearTimeout(timeoutId);
if (!fetchResponse.ok) {
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
}
const html = await fetchResponse.text();
const moviePattern =
/<div class="item">[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]+)<\/span>[\s\S]*?<\/div>/g;
const movies: DoubanItem[] = [];
let match;
while ((match = moviePattern.exec(html)) !== null) {
const title = match[1];
const cover = match[2];
const rate = match[3] || "";
const processedCover = cover.replace(/^http:/, "https:");
movies.push({ title, poster: processedCover, rate });
}
const apiResponse: DoubanResponse = {
code: 200,
message: "获取成功",
list: movies,
};
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(apiResponse);
} catch (error) {
clearTimeout(timeoutId);
res.status(500).json({
error: "获取豆瓣 Top250 数据失败",
details: (error as Error).message,
});
}
}
// --- Main Route Handler ---
router.get("/", async (req: Request, res: Response) => {
const { type, tag } = req.query;
const pageSize = parseInt((req.query.pageSize as string) || "16");
const pageStart = parseInt((req.query.pageStart as string) || "0");
if (!type || !tag) {
return res.status(400).json({ error: "缺少必要参数: type 或 tag" });
}
if (typeof type !== "string" || !["tv", "movie"].includes(type)) {
return res.status(400).json({ error: "type 参数必须是 tv 或 movie" });
}
if (pageSize < 1 || pageSize > 100) {
return res.status(400).json({ error: "pageSize 必须在 1-100 之间" });
}
if (pageStart < 0) {
return res.status(400).json({ error: "pageStart 不能小于 0" });
}
if (tag === "top250") {
return handleTop250(pageStart, res);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
const doubanData = await fetchDoubanData(target);
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
title: item.title,
poster: item.cover,
rate: item.rate,
}));
const response: DoubanResponse = {
code: 200,
message: "获取成功",
list: list,
};
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(response);
} catch (error) {
res.status(500).json({
error: "获取豆瓣数据失败",
details: (error as Error).message,
});
}
});
export default router;

View File

@@ -1,67 +0,0 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "favorites.json");
// Helper function to read data
const readFavorites = async () => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
// If file doesn't exist or is invalid json, return empty object
return {};
}
};
// Helper function to write data
const writeFavorites = async (data: any) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/favorites
router.get("/favorites", async (req: Request, res: Response) => {
const { key } = req.query;
const favorites = await readFavorites();
if (key) {
res.json(favorites[key as string] || null);
} else {
res.json(favorites);
}
});
// POST /api/favorites
router.post("/favorites", async (req: Request, res: Response) => {
const { key, favorite } = req.body;
if (!key || !favorite) {
return res.status(400).json({ message: "Missing key or favorite data" });
}
const favorites = await readFavorites();
favorites[key] = { ...favorite, save_time: Math.floor(Date.now() / 1000) };
await writeFavorites(favorites);
res.json({ success: true });
});
// DELETE /api/favorites
router.delete("/favorites", async (req: Request, res: Response) => {
const { key } = req.query;
let favorites = await readFavorites();
if (key) {
delete favorites[key as string];
} else {
// Clear all favorites if no key is provided
favorites = {};
}
await writeFavorites(favorites);
res.json({ success: true });
});
export default router;

View File

@@ -1,43 +0,0 @@
import { Router, Request, Response } from "express";
import { Readable } from "node:stream";
const router = Router();
router.get("/", async (req: Request, res: Response) => {
const imageUrl = req.query.url as string;
if (!imageUrl) {
return res.status(400).send("Missing image URL");
}
try {
const imageResponse = await fetch(imageUrl, {
headers: {
Referer: "https://movie.douban.com/",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
},
});
if (!imageResponse.ok) {
return res.status(imageResponse.status).send(imageResponse.statusText);
}
const contentType = imageResponse.headers.get("content-type");
if (contentType) {
res.setHeader("Content-Type", contentType);
}
if (imageResponse.body) {
const nodeStream = Readable.fromWeb(imageResponse.body as any);
nodeStream.pipe(res);
} else {
res.status(500).send("Image response has no body");
}
} catch (error) {
console.info("Image proxy error:", error);
res.status(500).send("Error fetching image");
}
});
export default router;

View File

@@ -1,24 +0,0 @@
import { Router } from "express";
import searchRouter from "./search";
import detailRouter from "./detail";
import doubanRouter from "./douban";
import imageProxyRouter from "./image-proxy";
import serverConfigRouter from "./server-config";
import loginRouter from "./login";
import favoritesRouter from "./favorites";
import playRecordsRouter from "./playrecords";
import searchHistoryRouter from "./searchhistory";
const router = Router();
router.use(serverConfigRouter);
router.use(loginRouter);
router.use(favoritesRouter);
router.use(playRecordsRouter);
router.use(searchHistoryRouter);
router.use("/search", searchRouter);
router.use("/detail", detailRouter);
router.use("/douban", doubanRouter);
router.use("/image-proxy", imageProxyRouter);
export default router;

View File

@@ -1,58 +0,0 @@
import express, { Request, Response } from "express";
import dotenv from "dotenv";
dotenv.config();
const router = express.Router();
const username = process.env.USERNAME;
const password = process.env.PASSWORD;
/**
* @api {post} /api/login User Login
* @apiName UserLogin
* @apiGroup User
*
* @apiBody {String} username User's username.
* @apiBody {String} password User's password.
*
* @apiSuccess {Boolean} ok Indicates if the login was successful.
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "ok": true
* }
*
* @apiError {String} message Error message.
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 400 Bad Request
* {
* "message": "Invalid password"
* }
*/
router.post("/login", (req: Request, res: Response) => {
const { username: inputUsername, password: inputPassword } = req.body;
// Compatibility with old versions, if username is not set, only password is required
if (!username || !password) {
if (inputPassword === password) {
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
return res.json({ ok: true });
} else if (!password) {
// If no password is set, login is always successful.
return res.json({ ok: true });
} else {
return res.status(400).json({ message: "Invalid password" });
}
}
if (inputUsername === username && inputPassword === password) {
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
res.json({ ok: true });
} else {
res.status(400).json({ message: "Invalid username or password" });
}
});
export default router;

View File

@@ -1,59 +0,0 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "playrecords.json");
// Helper function to read data
const readPlayRecords = async () => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
return {};
}
};
// Helper function to write data
const writePlayRecords = async (data: any) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/playrecords
router.get("/playrecords", async (req: Request, res: Response) => {
const records = await readPlayRecords();
res.json(records);
});
// POST /api/playrecords
router.post("/playrecords", async (req: Request, res: Response) => {
const { key, record } = req.body;
if (!key || !record) {
return res.status(400).json({ message: "Missing key or record data" });
}
const records = await readPlayRecords();
records[key] = { ...record, time: Math.floor(Date.now() / 1000) };
await writePlayRecords(records);
res.json({ success: true });
});
// DELETE /api/playrecords
router.delete("/playrecords", async (req: Request, res: Response) => {
const { key } = req.query;
let records = await readPlayRecords();
if (key) {
delete records[key as string];
} else {
records = {};
}
await writePlayRecords(records);
res.json({ success: true });
});
export default router;

View File

@@ -1,270 +0,0 @@
import { Router, Request, Response } from "express";
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from "../config";
import { cleanHtmlTags } from "../utils";
const router = Router();
// 根据环境变量决定最大搜索页数,默认 5
const MAX_SEARCH_PAGES: number = Number(process.env.SEARCH_MAX_PAGE) || 5;
export interface SearchResult {
id: string;
title: string;
poster: string;
episodes: string[];
source: string;
source_name: string;
class?: string;
year: string;
desc?: string;
type_name?: string;
}
interface ApiSearchItem {
vod_id: string;
vod_name: string;
vod_pic: string;
vod_remarks?: string;
vod_play_url?: string;
vod_class?: string;
vod_year?: string;
vod_content?: string;
type_name?: string;
}
async function searchFromApi(
apiSite: ApiSite,
query: string
): Promise<SearchResult[]> {
try {
const apiBaseUrl = apiSite.api;
const apiUrl =
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
const apiName = apiSite.name;
// 添加超时处理
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(apiUrl, {
headers: API_CONFIG.search.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return [];
}
const data = await response.json();
console.log(
"apiUrl",
apiSite.name,
"response status",
response.ok,
"response data",
data.list.length
);
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
return [];
}
// 处理第一页结果
const results = data.list.map((item: ApiSearchItem) => {
let episodes: string[] = [];
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
if (item.vod_play_url) {
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
// 先用 $$$ 分割
const vod_play_url_array = item.vod_play_url.split("$$$");
// 对每个分片做匹配,取匹配到最多的作为结果
vod_play_url_array.forEach((url: string) => {
const matches = url.match(m3u8Regex) || [];
if (matches.length > episodes.length) {
episodes = matches;
}
});
}
episodes = Array.from(new Set(episodes)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
return {
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes,
source: apiSite.key,
source_name: apiName,
class: item.vod_class,
year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || "" : "",
desc: cleanHtmlTags(item.vod_content || ""),
type_name: item.type_name,
};
});
// 获取总页数
const pageCount = data.pagecount || 1;
// 确定需要获取的额外页数
const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1);
// 如果有额外页数,获取更多页的结果
if (pagesToFetch > 0) {
const additionalPagePromises = [];
for (let page = 2; page <= pagesToFetch + 1; page++) {
const pageUrl =
apiBaseUrl +
API_CONFIG.search.pagePath
.replace("{query}", encodeURIComponent(query))
.replace("{page}", page.toString());
const pagePromise = (async () => {
try {
const pageController = new AbortController();
const pageTimeoutId = setTimeout(
() => pageController.abort(),
8000
);
const pageResponse = await fetch(pageUrl, {
headers: API_CONFIG.search.headers,
signal: pageController.signal,
});
clearTimeout(pageTimeoutId);
if (!pageResponse.ok) return [];
const pageData = await pageResponse.json();
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
return [];
return pageData.list.map((item: ApiSearchItem) => {
let episodes: string[] = [];
if (item.vod_play_url) {
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
episodes = item.vod_play_url.match(m3u8Regex) || [];
}
episodes = Array.from(new Set(episodes)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
return {
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes,
source: apiSite.key,
source_name: apiName,
class: item.vod_class,
year: item.vod_year
? item.vod_year.match(/\d{4}/)?.[0] || ""
: "",
desc: cleanHtmlTags(item.vod_content || ""),
type_name: item.type_name,
};
});
} catch (error) {
return [];
}
})();
additionalPagePromises.push(pagePromise);
}
const additionalResults = await Promise.all(additionalPagePromises);
additionalResults.forEach((pageResults) => {
if (pageResults.length > 0) {
results.push(...pageResults);
}
});
}
return results;
} catch (error) {
return [];
}
}
router.get("/", async (req: Request, res: Response) => {
const query = req.query.q as string;
if (!query) {
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
return res.json({ results: [] });
}
const apiSites = getApiSites();
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
try {
const results = await Promise.all(searchPromises);
const flattenedResults = results.flat();
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json({ results: flattenedResults });
} catch (error) {
res.status(500).json({ error: "搜索失败" });
}
});
// 按资源 url 单个获取数据
router.get("/one", async (req: Request, res: Response) => {
const { resourceId, q } = req.query;
if (!resourceId || !q) {
return res.status(400).json({ error: "resourceId and q are required" });
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === (resourceId as string));
if (!apiSite) {
return res.status(404).json({ error: "Resource not found" });
}
try {
const results = await searchFromApi(apiSite, q as string);
const result = results.filter((r) => r.title === (q as string));
if (results) {
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json({results: result});
} else {
res.status(404).json({ error: "Resource not found with the given query" });
}
} catch (error) {
res.status(500).json({ error: "Failed to fetch resource details" });
}
});
// 获取所有可用的资源列表
router.get("/resources", async (req: Request, res: Response) => {
const apiSites = getApiSites();
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(apiSites);
});
export default router;

View File

@@ -1,66 +0,0 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "searchhistory.json");
// Helper function to read data
const readSearchHistory = async (): Promise<string[]> => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
return [];
}
};
// Helper function to write data
const writeSearchHistory = async (data: string[]) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/searchhistory
router.get("/searchhistory", async (req: Request, res: Response) => {
const history = await readSearchHistory();
res.json(history);
});
// POST /api/searchhistory
router.post("/searchhistory", async (req: Request, res: Response) => {
const { keyword } = req.body;
if (!keyword) {
return res.status(400).json({ message: "Missing keyword" });
}
let history = await readSearchHistory();
// Remove keyword if it already exists to move it to the front
history = history.filter((item) => item !== keyword);
// Add to the beginning of the array
history.unshift(keyword);
// Optional: Limit history size
if (history.length > 100) {
history = history.slice(0, 100);
}
await writeSearchHistory(history);
res.json(history);
});
// DELETE /api/searchhistory
router.delete("/searchhistory", async (req: Request, res: Response) => {
const { keyword } = req.query;
let history = await readSearchHistory();
if (keyword) {
history = history.filter((item) => item !== keyword);
} else {
history = [];
}
await writeSearchHistory(history);
res.json({ success: true });
});
export default router;

View File

@@ -1,38 +0,0 @@
import express, { Request, Response } from "express";
import { getConfig } from "../config";
const router = express.Router();
/**
* @api {get} /api/server-config Get Server Configuration
* @apiName GetServerConfig
* @apiGroup Server
*
* @apiSuccess {String} SiteName The name of the site.
* @apiSuccess {String} StorageType The storage type used by the server ("localstorage" or "database").
*
* @apiSuccessExample {json} Success-Response (LocalStorage):
* HTTP/1.1 200 OK
* {
* "SiteName": "OrionTV-Local",
* "StorageType": "localstorage"
* }
*
* @apiSuccessExample {json} Success-Response (Database):
* HTTP/1.1 200 OK
* {
* "SiteName": "OrionTV-Cloud",
* "StorageType": "database"
* }
*/
router.get("/server-config", (req: Request, res: Response) => {
const config = getConfig();
const storageType = config.storage?.type || "database"; // Default to 'database' if not specified
res.json({
SiteName: storageType === "localstorage" ? "OrionTV-Local" : "OrionTV-Cloud",
StorageType: storageType,
});
});
export default router;

View File

@@ -1,28 +0,0 @@
// Data structure for play records
export interface PlayRecord {
title: string;
source_name: string;
poster: string;
index: number; // Episode number
total_episodes: number; // Total number of episodes
play_time: number; // Play progress in seconds
total_time: number; // Total duration in seconds
save_time: number; // Timestamp of when the record was saved
user_id: number; // User ID, always 0 in this version
}
// You can add other shared types here
export interface VideoDetail {
id: string;
title: string;
poster: string;
source: string;
source_name: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
}

View File

@@ -1,10 +0,0 @@
export function cleanHtmlTags(text: string): string {
if (!text) return "";
return text
.replace(/<[^>]+>/g, "\n") // 将 HTML 标签替换为换行
.replace(/\n+/g, "\n") // 将多个连续换行合并为一个
.replace(/[ \t]+/g, " ") // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, "") // 去掉首尾换行
.replace(/&nbsp;/g, " ") // 将 &nbsp; 替换为空格
.trim(); // 去掉首尾空格
}

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

View File

@@ -1,18 +0,0 @@
{
"version": 2,
"builds": [
{
"src": "src/index.ts",
"use": "@vercel/node",
"config": {
"includeFiles": ["./config.json"]
}
}
],
"routes": [
{
"src": "/(.*)",
"dest": "src/index.ts"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
import React, { useCallback } from "react";
import { View, StyleSheet, ScrollView, Dimensions, ActivityIndicator } from "react-native";
import { ThemedText } from "@/components/ThemedText";
interface CustomScrollViewProps {
data: any[];
renderItem: ({ item, index }: { item: any; index: number }) => React.ReactNode;
numColumns?: number;
loading?: boolean;
loadingMore?: boolean;
error?: string | null;
onEndReached?: () => void;
loadMoreThreshold?: number;
emptyMessage?: string;
ListFooterComponent?: React.ComponentType<any> | React.ReactElement | null;
}
const { width } = Dimensions.get("window");
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
data,
renderItem,
numColumns = 1,
loading = false,
loadingMore = false,
error = null,
onEndReached,
loadMoreThreshold = 200,
emptyMessage = "暂无内容",
ListFooterComponent,
}) => {
const ITEM_WIDTH = numColumns > 0 ? width / numColumns - 24 : width - 24;
const handleScroll = useCallback(
({ nativeEvent }: { nativeEvent: any }) => {
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - loadMoreThreshold;
if (isCloseToBottom && !loadingMore && onEndReached) {
onEndReached();
}
},
[onEndReached, loadingMore, loadMoreThreshold]
);
const renderFooter = () => {
if (ListFooterComponent) {
if (React.isValidElement(ListFooterComponent)) {
return ListFooterComponent;
} else if (typeof ListFooterComponent === "function") {
const Component = ListFooterComponent as React.ComponentType<any>;
return <Component />;
}
return null;
}
if (loadingMore) {
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
}
return null;
};
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" />
</View>
);
}
if (error) {
return (
<View style={styles.centerContainer}>
<ThemedText type="subtitle" style={{ padding: 10 }}>
{error}
</ThemedText>
</View>
);
}
if (data.length === 0) {
return (
<View style={styles.centerContainer}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.listContent} onScroll={handleScroll} scrollEventThrottle={16}>
{data.length > 0 ? (
<>
{/* Render content in a grid layout */}
{Array.from({ length: Math.ceil(data.length / numColumns) }).map((_, rowIndex) => (
<View key={rowIndex} style={styles.rowContainer}>
{data.slice(rowIndex * numColumns, (rowIndex + 1) * numColumns).map((item, index) => (
<View key={index} style={[styles.itemContainer, { width: ITEM_WIDTH }]}>
{renderItem({ item, index: rowIndex * numColumns + index })}
</View>
))}
</View>
))}
{renderFooter()}
</>
) : (
<View style={styles.centerContainer}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
)}
</ScrollView>
);
};
const styles = StyleSheet.create({
centerContainer: {
flex: 1,
paddingTop: 20,
justifyContent: "center",
alignItems: "center",
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 20,
},
rowContainer: {
flexDirection: "row",
justifyContent: "flex-start",
flexWrap: "wrap",
},
itemContainer: {
margin: 8,
alignItems: "center",
},
});
export default CustomScrollView;

View File

@@ -1,6 +1,7 @@
import React, { useRef, useState, useEffect } from "react";
import { View, StyleSheet, Text, ActivityIndicator } from "react-native";
import { Video, ResizeMode, AVPlaybackStatus } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
interface LivePlayerProps {
streamUrl: string | null;
@@ -15,6 +16,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
const [isLoading, setIsLoading] = useState(false);
const [isTimeout, setIsTimeout] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useKeepAwake();
useEffect(() => {
if (timeoutRef.current) {
@@ -66,7 +68,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
if (!streamUrl) {
return (
<View style={styles.container}>
<Text style={styles.messageText}>Select a channel to play.</Text>
<Text style={styles.messageText}></Text>
</View>
);
}
@@ -74,7 +76,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
if (isTimeout) {
return (
<View style={styles.container}>
<Text style={styles.messageText}>Failed to load stream. It might be offline or unavailable.</Text>
<Text style={styles.messageText}></Text>
</View>
);
}
@@ -98,7 +100,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#fff" />
<Text style={styles.messageText}>Loading...</Text>
<Text style={styles.messageText}>...</Text>
</View>
)}
{channelTitle && !isLoading && !isTimeout && (

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from "react";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, useTVEventHandler } from "react-native";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert } from "react-native";
import { usePathname } from "expo-router";
import Toast from "react-native-toast-message";
import useAuthStore from "@/stores/authStore";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -18,47 +19,26 @@ const LoginModal = () => {
const [isLoading, setIsLoading] = useState(false);
const usernameInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const loginButtonRef = useRef<View>(null);
const [focused, setFocused] = useState("username");
const tvEventHandler = (evt: any) => {
if (!evt || !isLoginModalVisible) {
return;
}
const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
if (evt.eventType === "down") {
if (focused === "username" && isUsernameVisible) {
passwordInputRef.current?.focus();
} else if (focused === "password") {
loginButtonRef.current?.focus();
}
}
if (evt.eventType === "up") {
if (focused === "button") {
passwordInputRef.current?.focus();
} else if (focused === "password" && isUsernameVisible) {
usernameInputRef.current?.focus();
}
}
};
useTVEventHandler(tvEventHandler);
const pathname = usePathname();
const isSettingsPage = pathname.includes("settings");
// Focus management with better TV remote handling
useEffect(() => {
if (isLoginModalVisible) {
if (isLoginModalVisible && !isSettingsPage) {
const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
setTimeout(() => {
// Use a small delay to ensure the modal is fully rendered
const focusTimeout = setTimeout(() => {
if (isUsernameVisible) {
usernameInputRef.current?.focus();
} else {
passwordInputRef.current?.focus();
}
}, 200);
}, 100);
return () => clearTimeout(focusTimeout);
}
}, [isLoginModalVisible, serverConfig]);
}, [isLoginModalVisible, serverConfig, isSettingsPage]);
const handleLogin = async () => {
const isLocalStorage = serverConfig?.StorageType === "localstorage";
@@ -75,15 +55,36 @@ const LoginModal = () => {
hideLoginModal();
setUsername("");
setPassword("");
} catch {
Toast.show({ type: "error", text1: "登录失败", text2: "用户名或密码错误" });
// Show disclaimer alert after successful login
Alert.alert(
"免责声明",
"本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
[{ text: "确定" }]
);
} catch (error) {
Toast.show({
type: "error",
text1: "登录失败",
text2: error instanceof Error ? error.message : "用户名或密码错误",
});
} finally {
setIsLoading(false);
}
};
// Handle navigation between inputs using returnKeyType
const handleUsernameSubmit = () => {
passwordInputRef.current?.focus();
};
return (
<Modal transparent={true} visible={isLoginModalVisible} animationType="fade" onRequestClose={hideLoginModal}>
<Modal
transparent={true}
visible={isLoginModalVisible && !isSettingsPage}
animationType="fade"
onRequestClose={hideLoginModal}
>
<View style={styles.overlay}>
<ThemedView style={styles.container}>
<ThemedText style={styles.title}></ThemedText>
@@ -97,7 +98,8 @@ const LoginModal = () => {
value={username}
onChangeText={setUsername}
returnKeyType="next"
onFocus={() => setFocused("username")}
onSubmitEditing={handleUsernameSubmit}
blurOnSubmit={false}
/>
)}
<TextInput
@@ -109,16 +111,14 @@ const LoginModal = () => {
value={password}
onChangeText={setPassword}
returnKeyType="go"
onFocus={() => setFocused("password")}
onSubmitEditing={handleLogin}
/>
<StyledButton
ref={loginButtonRef}
onFocus={() => setFocused("button")}
text={isLoading ? "" : "登录"}
onPress={handleLogin}
disabled={isLoading}
style={styles.button}
hasTVPreferredFocus={!serverConfig || serverConfig.StorageType === "localstorage"}
>
{isLoading && <ActivityIndicator color="#fff" />}
</StyledButton>

View File

@@ -168,7 +168,7 @@ const styles = StyleSheet.create({
position: "absolute",
left: 0,
height: 8,
backgroundColor: "#ff0000",
backgroundColor: "#fff",
borderRadius: 4,
},
progressBarTouchable: {

View File

@@ -80,7 +80,7 @@ const styles = StyleSheet.create({
},
seekingBarFilled: {
height: "100%",
backgroundColor: "#ff0000",
backgroundColor: "#fff",
borderRadius: 2.5,
},
});

View File

@@ -5,12 +5,24 @@ import useDetailStore from "@/stores/detailStore";
import usePlayerStore from "@/stores/playerStore";
export const SourceSelectionModal: React.FC = () => {
const { showSourceModal, setShowSourceModal } = usePlayerStore();
const { showSourceModal, setShowSourceModal, loadVideo, currentEpisodeIndex, status } = usePlayerStore();
const { searchResults, detail, setDetail } = useDetailStore();
const onSelectSource = (index: number) => {
console.log("onSelectSource", index, searchResults[index].source, detail?.source);
if (searchResults[index].source !== detail?.source) {
setDetail(searchResults[index]);
const newDetail = searchResults[index];
setDetail(newDetail);
// Reload the video with the new source, preserving current position
const currentPosition = status?.isLoaded ? status.positionMillis : undefined;
loadVideo({
source: newDetail.source,
id: newDetail.id.toString(),
episodeIndex: currentEpisodeIndex,
title: newDetail.title,
position: currentPosition
});
}
setShowSourceModal(false);
};
@@ -72,7 +84,9 @@ const styles = StyleSheet.create({
sourceItem: {
paddingVertical: 2,
margin: 4,
width: "31%",
marginLeft: 10,
marginRight: 8,
width: "30%",
},
sourceItemText: {
fontSize: 14,

View File

@@ -29,11 +29,10 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
color: colors.text,
},
selectedButton: {
backgroundColor: colors.tint,
backgroundColor: colors.primary,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
borderColor: colors.primary,
},
selectedText: {
color: Colors.dark.text,
@@ -47,11 +46,11 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
color: colors.text,
},
focusedButton: {
backgroundColor: colors.link,
backgroundColor: colors.primary,
borderColor: colors.background,
},
selectedButton: {
backgroundColor: "rgba(0, 122, 255, 0.3)",
backgroundColor: colors.primary,
},
selectedText: {
color: colors.link,
@@ -65,7 +64,8 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
color: colors.text,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
backgroundColor: "rgba(119, 119, 119, 0.2)",
borderColor: colors.primary,
},
selectedButton: {},
selectedText: {},

View File

@@ -1,13 +1,13 @@
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 React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
import { useRouter } from "expo-router";
import { Heart, Star, Play, Trash2 } from "lucide-react-native";
import { FavoriteManager, PlayRecordManager } from "@/services/storage";
import { API, api } from "@/services/api";
import { Star, Play } from "lucide-react-native";
import { PlayRecordManager } from "@/services/storage";
import { API } from "@/services/api";
import { ThemedText } from "@/components/ThemedText";
import { Colors } from "@/constants/Colors";
interface VideoCardProps {
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
id: string;
source: string;
title: string;
@@ -24,166 +24,191 @@ interface VideoCardProps {
api: API;
}
export default function VideoCard({
id,
source,
title,
poster,
year,
rate,
sourceName,
progress,
episodeIndex,
onFocus,
onRecordDeleted,
api,
playTime = 0,
}: VideoCardProps) {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
const VideoCard = forwardRef<View, VideoCardProps>(
(
{
id,
source,
title,
poster,
year,
rate,
sourceName,
progress,
episodeIndex,
onFocus,
onRecordDeleted,
api,
playTime = 0,
}: VideoCardProps,
ref
) => {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
const [fadeAnim] = useState(new Animated.Value(0));
const longPressTriggered = useRef(false);
const longPressTriggered = useRef(false);
const scale = useSharedValue(1);
const scale = useRef(new Animated.Value(1)).current;
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: scale.value }],
const animatedStyle = {
transform: [{ scale }],
};
});
const handlePress = () => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
// 如果有播放进度,直接转到播放页面
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({
pathname: "/detail",
params: { source, q: title },
});
}
};
const handlePress = () => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
// 如果有播放进度,直接转到播放页面
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({
pathname: "/detail",
params: { source, q: title },
});
}
};
const handleFocus = useCallback(() => {
setIsFocused(true);
scale.value = withSpring(1.05, { damping: 15, stiffness: 200 });
onFocus?.();
}, [scale, onFocus]);
const handleFocus = useCallback(() => {
setIsFocused(true);
Animated.spring(scale, {
toValue: 1.05,
damping: 15,
stiffness: 200,
useNativeDriver: true,
}).start();
onFocus?.();
}, [scale, onFocus]);
const handleBlur = useCallback(() => {
setIsFocused(false);
scale.value = withSpring(1.0);
}, [scale]);
const handleBlur = useCallback(() => {
setIsFocused(false);
Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
}).start();
}, [scale]);
const handleLongPress = () => {
// Only allow long press for items with progress (play records)
if (progress === undefined) return;
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
delay: Math.random() * 200, // 随机延迟创造交错效果
useNativeDriver: true,
}).start();
}, [fadeAnim]);
longPressTriggered.current = true;
const handleLongPress = () => {
// Only allow long press for items with progress (play records)
if (progress === undefined) return;
// 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);
longPressTriggered.current = true;
// Call the onRecordDeleted callback
if (onRecordDeleted) {
onRecordDeleted();
}
// 如果没有回调函数,则使用导航刷新作为备选方案
else if (router.canGoBack()) {
router.replace("/");
}
} catch (error) {
console.info("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
// 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);
// 是否是继续观看的视频
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
// Call the onRecordDeleted callback
if (onRecordDeleted) {
onRecordDeleted();
}
// 如果没有回调函数,则使用导航刷新作为备选方案
else if (router.canGoBack()) {
router.replace("/");
}
} catch (error) {
console.info("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
},
},
]);
};
return (
<Animated.View style={[styles.wrapper, animatedStyle]}>
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
activeOpacity={1}
delayLongPress={1000}
>
<View style={styles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{isFocused && (
<View style={styles.overlay}>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<ThemedText style={styles.continueWatchingText}></ThemedText>
</View>
)}
</View>
)}
// 是否是继续观看的视频
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
return (
<Animated.View style={[styles.wrapper, animatedStyle, { opacity: fadeAnim }]}>
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
activeOpacity={1}
delayLongPress={1000}
>
<View style={styles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{isFocused && (
<View style={styles.overlay}>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<ThemedText style={styles.continueWatchingText}></ThemedText>
</View>
)}
</View>
)}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<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}>
<ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</TouchableOpacity>
</Animated.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" />
<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}>
<ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</TouchableOpacity>
</Animated.View>
);
}
);
VideoCard.displayName = "VideoCard";
export default VideoCard;
const CARD_WIDTH = 160;
const CARD_HEIGHT = 240;
@@ -209,6 +234,9 @@ const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.3)",
borderColor: Colors.dark.primary,
borderWidth: 2,
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
},
@@ -247,9 +275,9 @@ const styles = StyleSheet.create({
infoContainer: {
width: CARD_WIDTH,
marginTop: 8,
alignItems: "flex-start", // Align items to the start
alignItems: "flex-start",
marginBottom: 16,
paddingHorizontal: 4, // Add some padding
paddingHorizontal: 4,
},
infoRow: {
flexDirection: "row",
@@ -290,17 +318,17 @@ const styles = StyleSheet.create({
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: "rgba(0, 0, 0, 0.5)",
height: 4,
backgroundColor: "rgba(0, 0, 0, 0.8)",
},
progressBar: {
height: 3,
backgroundColor: "#ff0000",
height: 4,
backgroundColor: Colors.dark.primary,
},
continueWatchingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(255, 0, 0, 0.8)",
backgroundColor: Colors.dark.primary,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 5,
@@ -312,7 +340,7 @@ const styles = StyleSheet.create({
fontWeight: "bold",
},
continueLabel: {
color: "#ff5252",
color: Colors.dark.primary,
fontSize: 12,
},
});
});

View File

@@ -0,0 +1,334 @@
import React, { useEffect, useRef } from "react";
import { View, StyleSheet, Animated, Easing } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
interface VideoLoadingAnimationProps {
showProgressBar?: boolean;
}
const VideoLoadingAnimation: React.FC<VideoLoadingAnimationProps> = ({ showProgressBar = true }) => {
const floatAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(0)).current;
const bounceAnims = [
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
];
const progressAnim = useRef(new Animated.Value(0)).current;
const gradientAnim = useRef(new Animated.Value(0)).current;
const textFadeAnim = useRef(new Animated.Value(0)).current;
const shapeAnims = [
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
];
useEffect(() => {
const floatAnimation = Animated.loop(
Animated.sequence([
Animated.timing(floatAnim, {
toValue: -20,
duration: 1500,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(floatAnim, {
toValue: 0,
duration: 1500,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
);
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(pulseAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
);
const bounceAnimations = bounceAnims.map((anim, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 160),
Animated.timing(anim, {
toValue: 1,
duration: 700,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(anim, {
toValue: 0,
duration: 700,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
)
);
const progressAnimation = Animated.loop(
Animated.timing(progressAnim, {
toValue: 1,
duration: 4000,
useNativeDriver: false, // width animation not supported by native driver
easing: Easing.inOut(Easing.ease),
})
);
const gradientAnimation = Animated.loop(
Animated.timing(gradientAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: false, // gradient animation not supported by native driver
easing: Easing.inOut(Easing.ease),
})
);
const textFadeAnimation = Animated.loop(
Animated.sequence([
Animated.timing(textFadeAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(textFadeAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
);
const shapeAnimations = shapeAnims.map((anim, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 2000),
Animated.timing(anim, {
toValue: 1,
duration: 8000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
)
);
Animated.parallel([
floatAnimation,
pulseAnimation,
...bounceAnimations,
progressAnimation,
gradientAnimation,
textFadeAnimation,
...shapeAnimations,
]).start();
}, []);
const animatedStyles = {
float: {
transform: [{ translateY: floatAnim }],
},
pulse: {
opacity: pulseAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0.7] }),
transform: [
{ translateX: -12.5 },
{ translateY: -15 },
{
scale: pulseAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 1.1] }),
},
],
},
bounce: bounceAnims.map((anim) => ({
transform: [{ scale: anim.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.2] }) }],
opacity: anim.interpolate({ inputRange: [0, 1], outputRange: [0.5, 1] }),
})),
progress: {
width: progressAnim.interpolate({
inputRange: [0, 0.7, 1],
outputRange: ["0%", "100%", "100%"],
}),
},
textFade: {
opacity: textFadeAnim.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }),
},
shapes: shapeAnims.map((anim, i) => ({
transform: [
{
translateY: anim.interpolate({
inputRange: [0, 0.33, 0.66, 1],
outputRange: [0, -30, 10, 0],
}),
},
{
rotate: anim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
}),
},
],
})),
};
return (
<View style={styles.container}>
<View style={styles.bgShapes}>
<Animated.View style={[styles.shape, styles.shape1, animatedStyles.shapes[0]]} />
<Animated.View style={[styles.shape, styles.shape2, animatedStyles.shapes[1]]} />
<Animated.View style={[styles.shape, styles.shape3, animatedStyles.shapes[2]]} />
<Animated.View style={[styles.shape, styles.shape4, animatedStyles.shapes[3]]} />
</View>
<View style={styles.loadingContainer}>
<Animated.View style={[styles.videoIcon, animatedStyles.float]}>
<View style={styles.videoFrame}>
<Animated.View style={[styles.playButton, animatedStyles.pulse]} />
</View>
</Animated.View>
{/* <View style={styles.loadingDots}>
<Animated.View style={[styles.dot, animatedStyles.bounce[0]]} />
<Animated.View style={[styles.dot, animatedStyles.bounce[1]]} />
<Animated.View style={[styles.dot, animatedStyles.bounce[2]]} />
</View> */}
{showProgressBar && (
<View style={styles.progressBar}>
<Animated.View style={[styles.progressFill, animatedStyles.progress]}>
<LinearGradient
colors={["#00bb5e", "#feff5f"]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</Animated.View>
</View>
)}
<Animated.Text style={[styles.loadingText, animatedStyles.textFade]}></Animated.Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
loadingContainer: {
alignItems: "center",
zIndex: 10,
},
videoIcon: {
width: 100,
height: 100,
marginBottom: 30,
},
videoFrame: {
width: "100%",
height: "100%",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 3,
borderColor: "rgba(255, 255, 255, 0.2)",
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
},
playButton: {
width: 0,
height: 0,
borderStyle: "solid",
borderLeftWidth: 25,
borderLeftColor: "rgba(255, 255, 255, 0.9)",
borderTopWidth: 15,
borderTopColor: "transparent",
borderBottomWidth: 15,
borderBottomColor: "transparent",
},
loadingDots: {
flexDirection: "row",
justifyContent: "center",
gap: 8,
marginBottom: 20,
},
dot: {
width: 12,
height: 12,
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: 6,
},
progressBar: {
width: 300,
height: 6,
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderRadius: 3,
marginVertical: 20,
overflow: "hidden",
},
progressFill: {
height: "100%",
borderRadius: 3,
},
loadingText: {
color: "rgba(255, 255, 255, 0.9)",
fontSize: 18,
fontWeight: "300",
letterSpacing: 2,
marginTop: 10,
},
bgShapes: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
},
shape: {
position: "absolute",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 50,
},
shape1: {
width: 80,
height: 80,
top: "20%",
left: "10%",
},
shape2: {
width: 60,
height: 60,
top: "60%",
right: "15%",
},
shape3: {
width: 100,
height: 100,
bottom: "20%",
left: "20%",
},
shape4: {
width: 40,
height: 40,
top: "30%",
right: "30%",
},
});
export default VideoLoadingAnimation;

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface APIConfigSectionProps {
onChanged: () => void;
@@ -75,7 +76,7 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
style={[styles.input, isInputFocused && styles.inputFocused]}
value={apiBaseUrl}
onChangeText={handleUrlChange}
placeholder="输入 API 地址"
placeholder="输入服务器地址"
placeholderTextColor="#888"
autoCapitalize="none"
autoCorrect={false}
@@ -126,8 +127,8 @@ const styles = StyleSheet.create({
borderColor: "transparent",
},
inputFocused: {
borderColor: "#007AFF",
shadowColor: "#007AFF",
borderColor: Colors.dark.primary,
shadowColor: Colors.dark.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface LiveStreamSectionProps {
onChanged: () => void;
@@ -120,8 +121,8 @@ const styles = StyleSheet.create({
borderColor: "transparent",
},
inputFocused: {
borderColor: "#007AFF",
shadowColor: "#007AFF",
borderColor: Colors.dark.primary,
shadowColor: Colors.dark.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface RemoteInputSectionProps {
onChanged: () => void;
@@ -59,7 +60,7 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
<Switch
value={remoteInputEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互
trackColor={{ false: "#767577", true: "#007AFF" }}
trackColor={{ false: "#767577", true: Colors.dark.primary }}
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
pointerEvents="none"
/>
@@ -70,7 +71,7 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
<View style={styles.statusContainer}>
<View style={styles.statusItem}>
<ThemedText style={styles.statusLabel}></ThemedText>
<ThemedText style={[styles.statusValue, { color: isServerRunning ? "#00FF00" : "#FF6B6B" }]}>
<ThemedText style={[styles.statusValue, { color: isServerRunning ? Colors.dark.primary : "#FF6B6B" }]}>
{isServerRunning ? "运行中" : "已停止"}
</ThemedText>
</View>

View File

@@ -1,6 +1,7 @@
import React, { useState } from "react";
import { StyleSheet, Pressable } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { Colors } from "@/constants/Colors";
interface SettingsSectionProps {
children: React.ReactNode;
@@ -9,12 +10,7 @@ interface SettingsSectionProps {
focusable?: boolean;
}
export const SettingsSection: React.FC<SettingsSectionProps> = ({
children,
onFocus,
onBlur,
focusable = false
}) => {
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, focusable = false }) => {
const [isFocused, setIsFocused] = useState(false);
const handleFocus = () => {
@@ -28,20 +24,12 @@ export const SettingsSection: React.FC<SettingsSectionProps> = ({
};
if (!focusable) {
return (
<ThemedView style={styles.section}>
{children}
</ThemedView>
);
return <ThemedView style={styles.section}>{children}</ThemedView>;
}
return (
<ThemedView style={[styles.section, isFocused && styles.sectionFocused]}>
<Pressable
style={styles.sectionPressable}
onFocus={handleFocus}
onBlur={handleBlur}
>
<Pressable style={styles.sectionPressable} onFocus={handleFocus} onBlur={handleBlur}>
{children}
</Pressable>
</ThemedView>
@@ -57,10 +45,10 @@ const styles = StyleSheet.create({
borderColor: "#333",
},
sectionFocused: {
borderColor: "#007AFF",
borderColor: Colors.dark.primary,
backgroundColor: "#007AFF10",
},
sectionPressable: {
width: "100%",
},
});
});

View File

@@ -26,5 +26,6 @@ export const Colors = {
tabIconSelected: tintColorDark,
link: "#0a7ea4",
border: "#333",
primary: "#00bb5e",
},
};

View File

@@ -1,284 +0,0 @@
# OrionTV Android 5.0 兼容性分析报告
## 项目概述
OrionTV是一个基于React Native TVOS和Expo SDK的电视端视频流媒体应用专为Apple TV和Android TV平台设计。本文档分析了将项目降级到支持Android 5.0 (API Level 21)的兼容性风险和实施方案。
## 当前技术栈
### 核心框架版本
- **React Native**: `npm:react-native-tvos@~0.74.2-0`
- **Expo SDK**: `~51.0.13`
- **React**: `18.2.0`
- **TypeScript**: `~5.3.3`
- **最小Android API级别**: 23 (Android 6.0)
- **目标Android API级别**: 34 (Android 14)
### 关键依赖
- `expo-av`: `~14.0.7` (视频播放)
- `expo-router`: `~3.5.16` (路由导航)
- `react-native-reanimated`: `~3.10.1` (动画)
- `react-native-tcp-socket`: `^6.0.6` (网络服务)
- `zustand`: `^5.0.6` (状态管理)
## 兼容性限制分析
### React Native 0.74 限制
根据官方文档React Native 0.74已将最低Android API级别要求提升到23 (Android 6.0)不再支持Android 5.0 (API Level 21)。
### Expo SDK 51 限制
Expo SDK 51基于React Native 0.74同样不支持Android 5.0。
## 降级方案
### 推荐的版本组合
#### 方案A: 保持TV功能的最新兼容版本
```json
{
"react-native": "npm:react-native-tvos@~0.73.8-0",
"expo": "~50.0.0",
"expo-av": "~13.10.x",
"expo-router": "~3.4.x",
"react-native-reanimated": "~3.8.x"
}
```
#### 方案B: 最大向后兼容版本
```json
{
"react-native": "npm:react-native-tvos@~0.72.12-0",
"expo": "~49.0.0"
}
```
### Android配置修改
```gradle
// android/build.gradle
android {
minSdkVersion = 21 // 支持Android 5.0
targetSdkVersion = 30 // 降级到Android 11
compileSdkVersion = 33 // 对应的编译SDK版本
}
```
## 风险评估
### 🔴 高风险组件
#### 1. 视频播放功能 (expo-av)
- **影响文件**: `hooks/usePlaybackManager.ts`, `app/play.tsx`, `stores/playerStore.ts`
- **风险**: API变化可能影响播放控制
- **关键代码**:
```typescript
import { Video, AVPlaybackStatus } from "expo-av";
// 可能受影响的API调用
videoRef?.current?.replayAsync();
videoRef?.current?.pauseAsync();
videoRef?.current?.playAsync();
```
#### 2. TV遥控器功能 (react-native-tvos)
- **影响文件**: `hooks/useTVRemoteHandler.ts` + 7个组件文件
- **风险**: 遥控器事件处理可能有变化
- **关键代码**:
```typescript
import { useTVEventHandler, HWEvent } from "react-native";
// 长按事件处理可能需要调整
case "longRight":
case "longLeft":
```
#### 3. 路由导航 (expo-router)
- **影响文件**: 9个页面文件
- **风险**: 路由配置和参数传递可能有变化
- **关键代码**:
```typescript
import { useRouter, useLocalSearchParams } from "expo-router";
```
### 🟡 中等风险组件
#### 1. 远程控制服务 (react-native-tcp-socket)
- **影响文件**: `services/tcpHttpServer.ts`
- **风险**: 网络API兼容性问题
- **关键代码**:
```typescript
import TcpSocket from 'react-native-tcp-socket';
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {});
```
#### 2. 动画效果 (react-native-reanimated)
- **影响文件**: `components/VideoCard.tv.tsx`
- **风险**: 动画性能可能下降
- **关键代码**:
```typescript
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
```
### 🟢 低风险组件
#### 1. 状态管理 (zustand)
- **影响**: 与React Native版本无直接关系
- **风险**: 极低
#### 2. 数据存储 (AsyncStorage)
- **影响文件**: `services/storage.ts`
- **风险**: 极低API稳定
## 平台特定风险
### Android API 23 → 21 降级影响
#### 1. 运行时权限模型
- **API 23+**: 需要运行时权限请求
- **API 21-22**: 安装时权限模型
- **影响**: 网络权限处理可能需要调整
#### 2. 网络安全配置
- **风险**: HTTP cleartext流量处理
- **当前配置**: `android.usesCleartextTraffic = true`
- **建议**: 保持当前配置确保向后兼容
#### 3. 后台服务限制
- **API 23+**: 更严格的后台服务限制
- **API 21-22**: 相对宽松的后台服务策略
- **影响**: 远程控制服务可能表现不同
## 实施步骤
### 1. 准备阶段
```bash
# 1. 备份当前项目
git checkout -b android-5-compatibility
# 2. 清理现有依赖
rm -rf node_modules
rm yarn.lock
```
### 2. 版本降级
```bash
# 3. 修改package.json依赖版本
# 4. 重新安装依赖
yarn install
# 5. 清理原生代码
yarn prebuild-tv --clean
```
### 3. 配置修改
```bash
# 6. 修改android/build.gradle
# 7. 更新app.json配置
# 8. 复制TV相关配置
yarn copy-config
```
### 4. 测试构建
```bash
# 9. 本地构建测试
yarn build-local
# 10. 运行测试
yarn test
```
## 测试清单
### 核心功能测试
- [ ] 视频播放、暂停、进度控制
- [ ] 遥控器所有按键响应(上下左右、选择、菜单、返回)
- [ ] 长按快进/快退功能
- [ ] 页面导航和参数传递
- [ ] 焦点管理和视觉反馈
### TV特定功能测试
- [ ] 控制条自动显示/隐藏
- [ ] 剧集切换功能
- [ ] 远程控制HTTP服务
- [ ] 设置页面各项配置
- [ ] 搜索功能
### 兼容性测试
- [ ] Android 5.0真机测试
- [ ] Android TV模拟器测试
- [ ] Apple TV模拟器测试
- [ ] 不同屏幕尺寸适配
- [ ] 内存使用情况
- [ ] 启动性能测试
## 依赖版本对照表
| 组件 | 当前版本 | 目标版本 | 风险等级 | 备注 |
|------|----------|----------|----------|------|
| react-native-tvos | ~0.74.2-0 | ~0.73.8-0 | 🔴 高 | TV功能核心 |
| expo | ~51.0.13 | ~50.0.0 | 🔴 高 | 框架基础 |
| expo-av | ~14.0.7 | ~13.10.x | 🔴 高 | 视频播放 |
| expo-router | ~3.5.16 | ~3.4.x | 🔴 高 | 路由导航 |
| react-native-reanimated | ~3.10.1 | ~3.8.x | 🟡 中 | 动画效果 |
| react-native-tcp-socket | ^6.0.6 | ^6.0.4 | 🟡 中 | 网络服务 |
| zustand | ^5.0.6 | ^5.0.6 | 🟢 低 | 状态管理 |
| @react-native-async-storage/async-storage | ^2.2.0 | ^2.1.x | 🟢 低 | 数据存储 |
## 潜在问题和解决方案
### 1. 视频播放问题
**问题**: expo-av版本降级可能导致某些视频格式不支持
**解决方案**:
- 测试主要视频格式(MP4, M3U8)
- 必要时实现格式转换
- 提供播放失败的友好提示
### 2. 遥控器响应问题
**问题**: TV事件处理可能有差异
**解决方案**:
- 仔细测试所有遥控器按键
- 调整事件处理逻辑
- 增加兼容性检查
### 3. 路由导航问题
**问题**: 页面跳转参数传递可能有变化
**解决方案**:
- 测试所有页面跳转
- 验证参数正确传递
- 必要时调整路由配置
### 4. 动画性能问题
**问题**: 动画可能在低端设备上表现不佳
**解决方案**:
- 简化动画效果
- 增加性能检测
- 提供动画开关选项
## 建议与结论
### 风险总结
- **总体风险等级**: 🔴 **高等风险**
- **主要风险点**: 视频播放、遥控器功能、路由导航
- **预计工作量**: 2-3周开发 + 1-2周测试
### 成本效益分析
- **开发成本**: 高(需要大量测试和调试)
- **维护成本**: 高(使用较旧版本,安全更新有限)
- **用户覆盖**: 低Android 5用户占比通常<2%
### 最终建议
**不建议进行降级**,原因如下:
1. 技术风险高,可能影响核心功能稳定性
2. 维护成本高,需要长期支持多个版本
3. 用户收益有限Android 5用户占比极低
4. 与业界趋势不符,各大平台都在提升最低版本要求
### 替代方案
1. **统计用户分布**: 收集实际用户设备数据确认Android 5用户占比
2. **渐进式升级**: 引导用户升级设备,提供升级指南
3. **精简版本**: 为老设备提供功能精简的独立版本
4. **Web版本**: 提供Web端访问方式作为补充
## 参考资料
- [React Native 0.74 Release Notes](https://reactnative.dev/blog/2024/04/22/release-0.74)
- [Expo SDK 51 Changelog](https://expo.dev/changelog/2024-05-07-sdk-51)
- [React Native TV OS Documentation](https://github.com/react-native-tvos/react-native-tvos)
- [Android API Level Distribution](https://developer.android.com/about/dashboards)

View File

@@ -1,313 +0,0 @@
### 服务器配置
- **接口地址**: `/api/server-config`
- **请求方法**: `GET`
- **功能说明**: 获取服务器配置信息
- **请求参数**: 无
- **返回格式**:
```json
{
"SiteName": "string",
"StorageType": "string"
}
```
StorageType 可选值:
- "localstorage"
- "redis"
localstorage 方式部署的实例,收藏、播放记录和搜索历史无服务器同步,客户端自行处理即可
localstorage 方式部署的实例,登录时只需输入密码,无用户名
### 登录校验
- **接口地址**: `/api/login`
- **请求方法**: `POST`
- **功能说明**: 用户登录认证
- **请求参数**:
```json
{
"password": "string", // 必填,用户密码
"username": "string" // 选填,用户名(非 localStorage 模式时必填)
}
```
- **返回格式**:
```json
{
"ok": true
}
```
- **错误码**:
- `400`: 参数错误或密码错误
- `500`: 服务器内部错误
response 会设置 set-cookie 的 auth 字段,用于后续请求的鉴权
后续的所有接口请求时都需要携带 auth 字段,否则会返回 401 错误
建议客户端保存用户输入的用户名和密码,在每次 app 启动时请求登录接口获取 cookie
### 视频搜索接口
- **接口地址**: `/api/search`
- **请求方法**: `GET`
- **功能说明**: 搜索视频内容
- **请求参数**:
- `q`: 搜索关键词(可选,不传返回空结果)
- **返回格式**:
```json
{
"results": [
{
"id": "string", // 视频在源站中的 id
"title": "string", // 视频标题
"poster": "string", // 视频封面
"source": "string", // 视频源站 key
"source_name": "string", // 视频源站名称
"class": "string", // 视频分类
"year": "string", // 视频年份
"desc": "string", // 视频描述
"type_name": "string", // 视频类型
"douban_id": "string" // 视频豆瓣 id
}
]
}
```
- **错误码**:
- `500`: 搜索失败
### 视频详情接口
- **接口地址**: `/api/detail`
- **请求方法**: `GET`
- **功能说明**: 获取视频详细信息
- **请求参数**:
- `id`: 视频 ID必填
- `source`: 视频来源代码(必填)
- **返回格式**:
```json
{
"id": "string", // 视频在源站中的 id
"title": "string", // 视频标题
"poster": "string", // 视频封面
"source": "string", // 视频源站 key
"source_name": "string", // 视频源站名称
"class": "string", // 视频分类
"year": "string", // 视频年份
"desc": "string", // 视频描述
"type_name": "string", // 视频类型
"douban_id": "string" // 视频豆瓣 id
}
```
- **错误码**:
- `400`: 缺少必要参数或无效参数
- `500`: 获取详情失败
### 豆瓣数据接口
- **接口地址**: `/api/douban`
- **请求方法**: `GET`
- **功能说明**: 获取豆瓣电影/电视剧数据
- **请求参数**:
- `type`: 类型,必须是 `tv` 或 `movie`(必填)
- `tag`: 标签,如 `热门`、`最新` 等(必填)
- `pageSize`: 每页数量1-100 之间(可选,默认 16
- `pageStart`: 起始位置,不能小于 0可选默认 0
- **返回格式**:
```json
{
"code": 200,
"message": "获取成功",
"list": [
{
"id": "string",
"title": "string",
"poster": "string",
"rate": "string"
}
]
}
```
- **错误码**:
- `400`: 参数错误
- `500`: 获取豆瓣数据失败
### 用户数据接口
#### 收藏管理
- **接口地址**: `/api/favorites`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户收藏
- **认证**: 需要认证
##### GET 请求 - 获取收藏
- **请求参数**:
- `key`: 收藏项 key可选格式为 `source+id`
- **返回格式**:
```json
// 不带key参数时返回所有收藏
{
"source+id": {
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
}
// 带key参数时返回单个收藏或null
{
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
```
##### POST 请求 - 添加收藏
- **请求参数**:
```json
{
"key": "string", // 必填,格式为 source+id
"favorite": {
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
}
```
- **返回格式**:
```json
{
"success": true
}
```
##### DELETE 请求 - 删除收藏
- **请求参数**:
- `key`: 收藏项 key可选不传则清空所有收藏
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误
#### 播放记录管理
- **接口地址**: `/api/playrecords`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户播放记录
- **认证**: 需要认证
##### GET 请求 - 获取播放记录
- **请求参数**: 无
- **返回格式**:
```json
{
"source+id": {
"title": "string",
"poster": "string",
"source_name": "string",
"index": 1,
"time": 1234567890
}
}
```
##### POST 请求 - 保存播放记录
- **请求参数**:
```json
{
"key": "string", // 必填,格式为 source+id
"record": {
"title": "string",
"poster": "string",
"source_name": "string",
"index": 1,
"time": 1234567890
}
}
```
- **返回格式**:
```json
{
"success": true
}
```
##### DELETE 请求 - 删除播放记录
- **请求参数**:
- `key`: 播放记录 key可选不传则清空所有记录
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误
#### 搜索历史管理
- **接口地址**: `/api/searchhistory`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户搜索历史
- **认证**: 需要认证
##### GET 请求 - 获取搜索历史
- **请求参数**: 无
- **返回格式**:
```json
["搜索关键词1", "搜索关键词2"]
```
##### POST 请求 - 添加搜索历史
- **请求参数**:
```json
{
"keyword": "string" // 必填,搜索关键词
}
```
- **返回格式**:
```json
["搜索关键词1", "搜索关键词2"]
```
##### DELETE 请求 - 删除搜索历史
- **请求参数**:
- `keyword`: 要删除的关键词(可选,不传则清空所有历史)
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误

View File

@@ -1,305 +0,0 @@
# OrionTV Native HTTP Server Implementation Documentation
## Overview
OrionTV implements a sophisticated native HTTP server solution that enables remote control functionality for the TV application. This implementation uses TCP sockets to create a custom HTTP server directly within the React Native application, providing a web-based remote control interface accessible from mobile devices.
## Architecture
### Core Components
#### 1. TCPHttpServer (`/services/tcpHttpServer.ts`)
A custom HTTP server implementation built on top of `react-native-tcp-socket` that handles raw TCP connections and implements HTTP protocol parsing and response formatting.
**Key Features:**
- Custom HTTP request/response parsing
- Fixed port configuration (12346)
- Automatic IP address detection via `@react-native-community/netinfo`
- Support for GET and POST methods
- Error handling and connection management
**Class Structure:**
```typescript
class TCPHttpServer {
private server: TcpSocket.Server | null = null;
private isRunning = boolean;
private requestHandler: RequestHandler | null = null;
}
```
**Core Methods:**
- `start()`: Initializes server and binds to `0.0.0.0:12346`
- `stop()`: Gracefully shuts down the server
- `setRequestHandler()`: Sets the request handling logic
- `parseHttpRequest()`: Parses raw HTTP request data
- `formatHttpResponse()`: Formats HTTP responses
#### 2. RemoteControlService (`/services/remoteControlService.ts`)
A service layer that wraps the TCPHttpServer and provides the remote control functionality with predefined routes and HTML interface.
**API Endpoints:**
- `GET /` - Serves HTML remote control interface
- `POST /message` - Receives messages from mobile devices
- `POST /handshake` - Connection handshake for mobile clients
**Features:**
- Built-in HTML interface generation
- JSON message parsing
- Callback-based event handling
- Error handling and validation
#### 3. RemoteControlStore (`/stores/remoteControlStore.ts`)
Zustand store that manages the remote control server state and provides React component integration.
**State Management:**
```typescript
interface RemoteControlState {
isServerRunning: boolean;
serverUrl: string | null;
error: string | null;
isModalVisible: boolean;
lastMessage: string | null;
startServer: () => Promise<void>;
stopServer: () => void;
showModal: () => void;
hideModal: () => void;
setMessage: (message: string) => void;
}
```
## Technical Implementation Details
### HTTP Protocol Implementation
#### Request Parsing
The server implements custom HTTP request parsing that handles:
- HTTP method and URL extraction
- Header parsing with case-insensitive keys
- Body content extraction
- Malformed request detection
#### Response Formatting
Responses are formatted according to HTTP/1.1 specification:
- Status line with appropriate status codes (200, 400, 404, 500)
- Content-Length header calculation
- Connection: close header for stateless operation
- Proper CRLF line endings
### Network Configuration
#### IP Address Detection
The server automatically detects the device's IP address using `@react-native-community/netinfo`:
- Supports WiFi and Ethernet connections
- Validates network connectivity before starting
- Provides clear error messages for network issues
#### Server Binding
- Binds to `0.0.0.0:12346` for universal access
- Fixed port configuration for consistency
- Supports all network interfaces on the device
### Security Considerations
#### Current Implementation
- No authentication mechanism
- Open access on local network
- Basic request validation
- Error handling prevents information disclosure
#### Limitations
- Suitable only for local network use
- No HTTPS/TLS encryption
- No rate limiting or DDoS protection
- Assumes trusted network environment
## Web Interface
### HTML Template
The service provides a responsive web interface optimized for mobile devices:
- Dark theme design matching TV app aesthetics
- Touch-friendly controls with large buttons
- Real-time message sending capability
- Automatic handshake on page load
### JavaScript Functionality
- Automatic handshake POST request on page load
- Message submission via JSON POST requests
- Input field clearing after submission
- Error handling for network issues
## Integration with React Native App
### App Initialization
The server is automatically started when the app launches (`/app/_layout.tsx`):
```typescript
useEffect(() => {
const { setMessage, hideModal } = useRemoteControlStore.getState();
remoteControlService.init({
onMessage: setMessage,
onHandshake: hideModal,
});
useRemoteControlStore.getState().startServer();
return () => {
useRemoteControlStore.getState().stopServer();
};
}, []);
```
### Message Handling
Messages received from mobile devices are processed and displayed as Toast notifications in the TV app, providing visual feedback for remote interactions.
### QR Code Integration
The app generates QR codes containing the server URL (`http://{device_ip}:12346`) for easy mobile device connection via `RemoteControlModal.tsx`.
## Dependencies
### Required Packages
- `react-native-tcp-socket@^6.0.6` - TCP socket implementation
- `@react-native-community/netinfo@^11.3.2` - Network interface information
- `react-native-qrcode-svg@^6.3.1` - QR code generation for UI
### Platform Compatibility
- iOS (Apple TV)
- Android (Android TV)
- Requires network connectivity (WiFi or Ethernet)
## Performance Characteristics
### Connection Handling
- Single-threaded event-driven architecture
- Stateless HTTP connections with immediate closure
- Memory-efficient request buffering
- Graceful error recovery
### Resource Usage
- Minimal CPU overhead for HTTP parsing
- Low memory footprint
- Network I/O bound operations
- Automatic connection cleanup
## Error Handling
### Server Level
- Network binding failures with descriptive messages
- Socket error handling and logging
- Graceful server shutdown procedures
- IP address detection error handling
### Request Level
- Malformed HTTP request detection
- JSON parsing error handling
- 400/404/500 status code responses
- Request timeout and connection cleanup
## Debugging and Monitoring
### Logging
Comprehensive logging throughout the system:
- Server startup/shutdown events
- Client connection/disconnection
- Request processing details
- Error conditions and stack traces
### Console Output Format
```
[TCPHttpServer] Server listening on 192.168.1.100:12346
[RemoteControl] Received request: POST /message
[RemoteControlStore] Server started, URL: http://192.168.1.100:12346
```
## Usage Example
### Starting the Server
```typescript
// Automatic startup via store
const { startServer } = useRemoteControlStore();
await startServer();
// Manual service usage
await remoteControlService.startServer();
```
### Stopping the Server
```typescript
// Via store
const { stopServer } = useRemoteControlStore();
stopServer();
// Direct service call
remoteControlService.stopServer();
```
### Mobile Device Access
1. Ensure mobile device is on the same network as TV
2. Scan QR code displayed in TV app
3. Access web interface at `http://{tv_ip}:12346`
4. Send messages that appear as notifications on TV
## Comparison with Alternatives
### vs react-native-http-bridge
- **Advantages**: More control over HTTP implementation, custom error handling
- **Disadvantages**: More complex implementation, requires manual HTTP parsing
### vs External Backend Server
- **Advantages**: No additional infrastructure, embedded in app
- **Disadvantages**: Limited scalability, single device constraint
## Future Enhancement Opportunities
### Security
- Authentication token implementation
- HTTPS/TLS encryption support
- Request rate limiting
- CORS configuration
### Functionality
- Multi-device support
- WebSocket integration for real-time communication
- File upload/download capabilities
- Advanced remote control commands
### Performance
- Connection pooling
- Request caching
- Compression support
- IPv6 compatibility
## Troubleshooting
### Common Issues
#### "Unable to get IP address" Error
- Verify WiFi/Ethernet connection
- Check network interface availability
- Restart network services
#### Server Won't Start
- Check if port 12346 is already in use
- Verify network permissions
- Restart the application
#### Mobile Device Can't Connect
- Confirm both devices on same network
- Verify firewall settings
- Check IP address in QR code
### Diagnostic Commands
```bash
# Check network connectivity
yarn react-native log-ios # View iOS logs
yarn react-native log-android # View Android logs
# Network debugging
netstat -an | grep 12346 # Check port binding (debugging environment)
```
## Conclusion
The OrionTV native HTTP server implementation provides a robust, embedded solution for remote control functionality without requiring external infrastructure. The custom TCP-based approach offers flexibility and control while maintaining simplicity and performance suitable for TV applications.
The implementation demonstrates sophisticated understanding of HTTP protocol handling, React Native integration, and TV-specific user experience requirements, making it an effective solution for cross-device interaction in smart TV environments.

View File

@@ -1,136 +0,0 @@
# 手机遥控功能实现方案 (V2)
本文档详细描述了在 OrionTV 应用中集成一个基于 **HTTP 请求** 的手机遥控功能的完整方案。
---
## 1. 核心功能与流程
该功能允许用户通过手机浏览器向 TV 端发送文本消息TV 端接收后以 Toast 形式进行展示。服务将在应用启动时自动开启,用户可在设置中找到入口以显示连接二维码。
### 流程图
```mermaid
sequenceDiagram
participant App as App 启动
participant RemoteControlStore as 状态管理 (TV)
participant RemoteControlService as 遥控服务 (TV)
participant User as 用户
participant SettingsUI as 设置界面 (TV)
participant PhoneBrowser as 手机浏览器
App->>RemoteControlStore: App 启动, 触发 startHttpServer()
RemoteControlStore->>RemoteControlService: 启动 HTTP 服务
RemoteControlService-->>RemoteControlStore: 更新服务状态 (IP, Port)
User->>SettingsUI: 打开设置, 点击“手机遥控”按钮
SettingsUI->>RemoteControlStore: 获取服务 URL
RemoteControlStore-->>SettingsUI: 返回 serverUrl
SettingsUI-->>User: 显示二维码弹窗
User->>PhoneBrowser: 扫描二维码
PhoneBrowser->>RemoteControlService: (HTTP GET) 请求网页
RemoteControlService-->>PhoneBrowser: 返回 HTML 页面
User->>PhoneBrowser: 输入文本并发送
PhoneBrowser->>RemoteControlService: (HTTP POST /message) 发送消息
RemoteControlService->>RemoteControlStore: 处理消息 (显示 Toast)
```
---
## 2. 技术选型
* **HTTP 服务**: `react-native-http-bridge`
* **二维码生成**: `react-native-qrcode-svg`
* **网络信息 (IP 地址)**: `@react-native-community/netinfo`
* **状态管理**: `zustand` (项目已集成)
---
## 3. 项目结构变更
### 新增文件
* `services/remoteControlService.ts`: 封装 HTTP 服务的核心逻辑。
* `stores/remoteControlStore.ts`: 使用 Zustand 管理远程控制服务的状态。
* `components/RemoteControlModal.tsx`: 显示二维码和连接信息的弹窗组件。
* `types/react-native-http-bridge.d.ts`: `react-native-http-bridge` 的 TypeScript 类型定义。
### 修改文件
* `app/_layout.tsx`: 在应用根组件中调用服务启动逻辑。
* `components/SettingsModal.tsx`: 添加“手机遥控”按钮,用于触发二维码弹窗。
* `package.json`: 添加新依赖。
---
## 4. 实现细节
### a. 状态管理 (`stores/remoteControlStore.ts`)
创建一个 Zustand store 来管理遥控服务的状态。
* **State**:
* `isServerRunning`: `boolean` - 服务是否正在运行。
* `serverUrl`: `string | null` - 完整的 HTTP 服务 URL (e.g., `http://192.168.1.5:12346`)。
* `error`: `string | null` - 错误信息。
* **Actions**:
* `startServer()`: 异步 action调用 `remoteControlService.startServer` 并更新 state。
* `stopServer()`: 调用 `remoteControlService.stopServer` 并更新 state。
### b. 服务层 (`services/remoteControlService.ts`)
实现服务的启动、停止和消息处理。
* **`startServer()`**:
1. 使用 `@react-native-community/netinfo` 获取 IP 地址。
2. 定义一个包含 `fetch` API 调用逻辑的 HTML 字符串。
3. 使用 `react-native-http-bridge` 在固定端口(如 `12346`)启动 HTTP 服务。
4. 配置 `GET /` 路由以返回 HTML 页面。
5. 配置 `POST /message` 路由来接收手机端发送的消息,并使用 `Toast` 显示。
6. 返回服务器 URL。
* **`stopServer()`**:
1. 调用 `httpBridge.stop()`
### c. UI 集成
* **`app/_layout.tsx`**:
* 在根组件 `useEffect` 中调用 `useRemoteControlStore.getState().startServer()`,实现服务自启。
* **`components/SettingsModal.tsx`**:
* 添加一个 `<StyledButton text="手机遥控" />`
* 点击按钮时,触发 `RemoteControlModal` 的显示。
* **`components/RemoteControlModal.tsx`**:
*`remoteControlStore` 中获取 `serverUrl`
* 如果 `serverUrl` 存在,则使用 `react-native-qrcode-svg``<QRCode />` 组件显示二维码。
* 如果不存在,则显示加载中或错误信息。
### d. 网页内容 (HTML)
一个简单的 HTML 页面,包含一个输入框和一个按钮。
```html
<html>
<head>
<title>OrionTV Remote</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> /* ... some basic styles ... */ </style>
</head>
<body>
<h3>发送消息到 TV</h3>
<input id="text" />
<button onclick="send()">发送</button>
<script>
function send() {
const val = document.getElementById("text").value;
if (val) {
fetch("/message", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: val })
});
document.getElementById("text").value = '';
}
}
</script>
</body>
</html>

View File

@@ -1,131 +0,0 @@
# 设置页面重构方案
## 目标
1. 将设置从弹窗模式改为独立页面
2. 新增直播源配置功能
3. 新增远程输入开关配置
4. 新增播放源启用配置
## 现有架构分析
### 当前设置相关文件:
- `stores/settingsStore.ts` - 设置状态管理目前只有API地址配置
- `components/SettingsModal.tsx` - 设置弹窗组件
- `stores/remoteControlStore.ts` - 远程控制状态管理
### 现有功能:
- API基础地址配置
- 远程控制服务器(但未集成到设置中)
## 重构方案
### 1. 创建独立设置页面
- 新建 `app/settings.tsx` 页面
- 使用 Expo Router 的文件路由系统
- 删除现有的 `SettingsModal.tsx` 组件
### 2. 扩展设置Store
`settingsStore.ts` 中新增以下配置项:
```typescript
interface SettingsState {
// 现有配置
apiBaseUrl: string;
// 新增配置项
liveStreamSources: LiveStreamSource[]; // 直播源配置
remoteInputEnabled: boolean; // 远程输入开关
videoSourceConfig: VideoSourceConfig; // 播放源配置
}
interface LiveStreamSource {
id: string;
name: string;
url: string;
enabled: boolean;
}
interface VideoSourceConfig {
primarySource: string;
fallbackSources: string[];
enabledSources: string[];
}
```
### 3. 设置页面UI结构
```
设置页面 (app/settings.tsx)
├── API 配置区域
│ └── API 基础地址输入框
├── 直播源配置区域
│ ├── 直播源列表
│ ├── 添加直播源按钮
│ └── 编辑/删除直播源功能
├── 远程输入配置区域
│ └── 远程输入开关
└── 播放源配置区域
├── 主播放源选择
├── 备用播放源配置
└── 启用的播放源选择
```
### 4. 组件设计
- 使用 TV 适配的组件和样式
- 实现焦点管理和遥控器导航
- 遵循现有的设计规范ThemedView, ThemedText, StyledButton
### 5. 导航集成
- 在主页面添加设置入口
- 使用 Expo Router 的 router.push('/settings') 进行导航
## 实施步骤
1. **扩展 settingsStore.ts**
- 添加新的状态接口
- 实现新配置项的增删改查方法
- 集成本地存储
2. **创建设置页面**
- 新建 `app/settings.tsx`
- 实现基础页面结构和导航
3. **实现配置组件**
- API 配置组件(复用现有逻辑)
- 直播源配置组件
- 远程输入开关组件
- 播放源配置组件
4. **集成远程控制**
- 将远程控制功能集成到设置页面
- 统一管理所有设置项
5. **更新导航**
- 在主页面添加设置入口
- 移除现有的设置弹窗触发逻辑
6. **测试验证**
- 测试所有配置项的保存和加载
- 验证TV平台的交互体验
- 确保配置项生效
## 技术考虑
### TV平台适配
- 使用 `useTVRemoteHandler` 处理遥控器事件
- 实现合适的焦点管理
- 确保所有交互元素可通过遥控器操作
### 数据持久化
- 使用现有的 `SettingsManager` 进行本地存储
- 确保新配置项能正确保存和恢复
### 向后兼容
- 保持现有API配置功能不变
- 为新配置项提供默认值
- 处理旧版本设置数据的迁移
## 预期收益
1. **更好的用户体验**:独立页面提供更多空间展示配置选项
2. **功能扩展性**:为未来添加更多配置项提供良好基础
3. **代码组织**:将设置相关功能集中管理
4. **TV平台适配**:更好的遥控器交互体验

View File

@@ -1,77 +0,0 @@
# StyledButton 组件设计文档
## 1. 目的
为了统一整个应用中的按钮样式和行为,减少代码重复,并提高开发效率和一致性,我们设计了一个通用的 `StyledButton` 组件。
该组件将取代以下位置的自定义 `Pressable``TouchableOpacity` 实现:
- `app/index.tsx` (分类按钮, 头部图标按钮)
- `components/DetailButton.tsx`
- `components/EpisodeSelectionModal.tsx` (剧集分组按钮, 剧集项按钮, 关闭按钮)
- `components/SettingsModal.tsx` (取消和保存按钮)
- `app/search.tsx` (清除按钮)
- `components/MediaButton.tsx` (媒体控制按钮)
- `components/NextEpisodeOverlay.tsx` (取消按钮)
## 2. API 设计
`StyledButton` 组件将基于 React Native 的 `Pressable` 构建,并提供以下 props
```typescript
import { PressableProps, StyleProp, ViewStyle, TextStyle } from "react-native";
interface StyledButtonProps extends PressableProps {
// 按钮的主要内容,可以是文本或图标等 React 节点
children?: React.ReactNode;
// 如果按钮只包含文本,可以使用此 prop 快速设置
text?: string;
// 按钮的视觉变体,用于应用不同的预设样式
// 'default': 默认灰色背景
// 'primary': 主题色背景,用于关键操作
// 'ghost': 透明背景,通常用于图标按钮
variant?: "default" | "primary" | "ghost";
// 按钮是否处于选中状态
isSelected?: boolean;
// 覆盖容器的样式
style?: StyleProp<ViewStyle>;
// 覆盖文本的样式 (当使用 `text` prop 时生效)
textStyle?: StyleProp<TextStyle>;
}
```
## 3. 样式和行为
### 状态样式:
- **默认状态 (`default`)**:
- 背景色: `#333`
- 边框: `transparent`
- **聚焦状态 (`focused`)**:
- 背景色: `#0056b3` (深蓝色)
- 边框: `#fff`
- 阴影/光晕效果
- 轻微放大 (`transform: scale(1.1)`)
- **选中状态 (`isSelected`)**:
- 背景色: `#007AFF` (亮蓝色)
- **主操作 (`primary`)**:
- 默认背景色: `#007AFF`
- **透明背景 (`ghost`)**:
- 默认背景色: `transparent`
### 结构:
组件内部将使用 `Pressable` 作为根元素,并根据 `focused``isSelected` props 动态计算样式。如果 `children``text` prop 都提供了,`children` 将优先被渲染。
## 4. 实现计划
1. **创建 `components/StyledButton.tsx` 文件**
2. **实现上述 API 和样式逻辑**
3. **逐个重构目标文件**,将原有的 `Pressable`/`TouchableOpacity` 替换为新的 `StyledButton` 组件。
4. **删除旧的、不再需要的样式**
5. **测试所有被修改的界面**,确保按钮的功能和视觉效果符合预期。

View File

@@ -2,7 +2,7 @@
"name": "OrionTV",
"private": true,
"main": "expo-router/entry",
"version": "1.2.2",
"version": "1.2.9",
"scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
@@ -36,6 +36,7 @@
"expo-build-properties": "~0.12.3",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.7",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.16",
"expo-splash-screen": "~0.27.5",
@@ -79,4 +80,4 @@
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}

View File

@@ -43,7 +43,6 @@ export interface SearchResult {
export interface Favorite {
cover: string;
title: string;
poster: string;
source_name: string;
total_episodes: number;
search_title: string;

View File

@@ -16,23 +16,37 @@ export const parseM3U = (m3uText: string): Channel[] => {
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('#EXTINF:')) {
currentChannelInfo = { id: '', name: '', url: '', logo: '', group: '' };
const commaIndex = trimmedLine.indexOf(',');
currentChannelInfo = {}; // Start a new channel
const commaIndex = trimmedLine.lastIndexOf(',');
if (commaIndex !== -1) {
currentChannelInfo.name = trimmedLine.substring(commaIndex + 1).trim();
const attributesPart = trimmedLine.substring(8, commaIndex);
const logoMatch = attributesPart.match(/tvg-logo="([^"]*)"/i);
if (logoMatch && logoMatch[1]) currentChannelInfo.logo = logoMatch[1];
if (logoMatch && logoMatch[1]) {
currentChannelInfo.logo = logoMatch[1];
}
const groupMatch = attributesPart.match(/group-title="([^"]*)"/i);
if (groupMatch && groupMatch[1]) currentChannelInfo.group = groupMatch[1];
if (groupMatch && groupMatch[1]) {
currentChannelInfo.group = groupMatch[1];
}
} else {
currentChannelInfo.name = trimmedLine.substring(8).trim();
}
} else if (currentChannelInfo && trimmedLine && !trimmedLine.startsWith('#') && trimmedLine.includes('://')) {
currentChannelInfo.url = trimmedLine;
currentChannelInfo.id = currentChannelInfo.url; // Use URL as ID
parsedChannels.push(currentChannelInfo as Channel);
currentChannelInfo = null;
// Ensure all required fields are present, providing defaults if necessary
const finalChannel: Channel = {
id: currentChannelInfo.id,
url: currentChannelInfo.url,
name: currentChannelInfo.name || 'Unknown',
logo: currentChannelInfo.logo || '',
group: currentChannelInfo.group || 'Default',
};
parsedChannels.push(finalChannel);
currentChannelInfo = null; // Reset for the next channel
}
}
return parsedChannels;
@@ -57,14 +71,14 @@ export const getPlayableUrl = (originalUrl: string | null): string | null => {
return null;
}
// In React Native, we use the proxy for all http streams to avoid potential issues.
if (originalUrl.toLowerCase().startsWith('http://')) {
// Use the baseURL from the existing api instance.
if (!api.baseURL) {
console.warn("API base URL is not set. Cannot create proxy URL.")
return originalUrl; // Fallback to original URL
}
return `${api.baseURL}/proxy?url=${encodeURIComponent(originalUrl)}`;
}
// if (originalUrl.toLowerCase().startsWith('http://')) {
// // Use the baseURL from the existing api instance.
// if (!api.baseURL) {
// console.warn("API base URL is not set. Cannot create proxy URL.")
// return originalUrl; // Fallback to original URL
// }
// return `${api.baseURL}/proxy?url=${encodeURIComponent(originalUrl)}`;
// }
// HTTPS streams can be played directly.
return originalUrl;
};
};

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import Cookies from "@react-native-cookies/cookies";
import { api } from "@/services/api";
import { useSettingsStore } from "./settingsStore";
import Toast from "react-native-toast-message";
interface AuthState {
isLoggedIn: boolean;
@@ -24,6 +25,10 @@ const useAuthStore = create<AuthState>((set) => ({
}
try {
const serverConfig = useSettingsStore.getState().serverConfig;
if (!serverConfig?.StorageType) {
Toast.show({ type: "error", text1: "请检查网络或者服务器地址是否可用" });
return
}
const cookies = await Cookies.get(api.baseURL);
if (serverConfig && serverConfig.StorageType === "localstorage" && !cookies.auth) {
const loginResult = await api.login().catch(() => {

View File

@@ -133,11 +133,11 @@ const useDetailStore = create<DetailState>((set, get) => ({
set({ error: "未找到任何播放源" });
}
// if (get().detail) {
// const { source, id } = get().detail!;
// const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
// set({ isFavorited });
// }
if (get().detail) {
const { source, id } = get().detail!;
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
set({ isFavorited });
}
} catch (e) {
if ((e as Error).name !== "AbortError") {
set({ error: e instanceof Error ? e.message : "获取数据失败" });
@@ -151,9 +151,9 @@ const useDetailStore = create<DetailState>((set, get) => ({
setDetail: async (detail) => {
set({ detail });
// const { source, id } = detail;
// const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
// set({ isFavorited });
const { source, id } = detail;
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
set({ isFavorited });
},
abort: () => {

View File

@@ -70,6 +70,9 @@ interface HomeState {
refreshPlayRecords: () => Promise<void>;
}
// 内存缓存,应用生命周期内有效
const dataCache = new Map<string, RowItem[]>();
const useHomeStore = create<HomeState>((set, get) => ({
categories: initialCategories,
selectedCategory: initialCategories[0],
@@ -83,6 +86,29 @@ const useHomeStore = create<HomeState>((set, get) => ({
fetchInitialData: async () => {
const { apiBaseUrl } = useSettingsStore.getState();
await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
const { selectedCategory } = get();
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
// 最近播放不缓存,始终实时获取
if (selectedCategory.type === 'record') {
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData();
return;
}
// 检查缓存
if (dataCache.has(cacheKey)) {
set({
loading: false,
contentData: dataCache.get(cacheKey)!,
pageStart: dataCache.get(cacheKey)!.length,
hasMore: false,
error: null
});
return;
}
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData();
},
@@ -133,11 +159,25 @@ const useHomeStore = create<HomeState>((set, get) => ({
id: item.title,
source: "douban",
})) as RowItem[];
set((state) => ({
contentData: pageStart === 0 ? newItems : [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length,
hasMore: true,
}));
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
if (pageStart === 0) {
// 缓存新数据
dataCache.set(cacheKey, newItems);
set((state) => ({
contentData: newItems,
pageStart: result.list.length,
hasMore: true,
}));
} else {
// 增量加载时不缓存,直接追加
set((state) => ({
contentData: [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length,
hasMore: true,
}));
}
}
} else if (selectedCategory.tags) {
// It's a container category, do not load content, but clear current content
@@ -147,7 +187,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
}
} catch (err: any) {
if (err.message === "API_URL_NOT_SET") {
set({ error: "请点击右上角设置按钮,配置您的 API 地址" });
set({ error: "请点击右上角设置按钮,配置您的服务器地址" });
} else {
set({ error: "加载失败,请重试" });
}
@@ -158,10 +198,29 @@ const useHomeStore = create<HomeState>((set, get) => ({
selectCategory: (category: Category) => {
const currentCategory = get().selectedCategory;
// Only fetch new data if the category or tag actually changes
const cacheKey = `${category.title}-${category.tag || ''}`;
// 只有当分类或标签真正变化时才处理
if (currentCategory.title !== category.title || currentCategory.tag !== category.tag) {
set({ selectedCategory: category, contentData: [], pageStart: 0, hasMore: true, error: null });
get().fetchInitialData();
// 最近播放始终实时获取
if (category.type === 'record') {
get().fetchInitialData();
return;
}
// 检查缓存,有则直接使用,无则请求
if (dataCache.has(cacheKey)) {
set({
contentData: dataCache.get(cacheKey)!,
pageStart: dataCache.get(cacheKey)!.length,
hasMore: false,
loading: false
});
} else {
get().fetchInitialData();
}
}
},
@@ -199,6 +258,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
}
return {};
});
get().fetchInitialData();
},
}));

View File

@@ -27,7 +27,13 @@ interface PlayerState {
introEndTime?: number;
outroStartTime?: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (options: {source: string, id: string, title: string; episodeIndex: number, position?: number}) => Promise<void>;
loadVideo: (options: {
source: string;
id: string;
title: string;
episodeIndex: number;
position?: number;
}) => Promise<void>;
playEpisode: (index: number) => void;
togglePlayPause: () => void;
seek: (duration: number) => void;
@@ -41,8 +47,9 @@ interface PlayerState {
setOutroStartTime: () => void;
reset: () => void;
_seekTimeout?: NodeJS.Timeout;
_isRecordSaveThrottled: boolean;
// Internal helper
_savePlayRecord: (updates?: Partial<PlayRecord>) => void;
_savePlayRecord: (updates?: Partial<PlayRecord>, options?: { immediate?: boolean }) => void;
}
const usePlayerStore = create<PlayerState>((set, get) => ({
@@ -62,6 +69,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
introEndTime: undefined,
outroStartTime: undefined,
_seekTimeout: undefined,
_isRecordSaveThrottled: false,
setVideoRef: (ref) => set({ videoRef: ref }),
@@ -81,7 +89,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
console.info("Detail not found after initialization");
return;
}
};
}
try {
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
@@ -170,7 +178,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
if (existingIntroEndTime) {
// Clear the time
set({ introEndTime: undefined });
get()._savePlayRecord({ introEndTime: undefined });
get()._savePlayRecord({ introEndTime: undefined }, { immediate: true });
Toast.show({
type: "info",
text1: "已清除片头时间",
@@ -179,7 +187,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
// Set the time
const newIntroEndTime = status.positionMillis;
set({ introEndTime: newIntroEndTime });
get()._savePlayRecord({ introEndTime: newIntroEndTime });
get()._savePlayRecord({ introEndTime: newIntroEndTime }, { immediate: true });
Toast.show({
type: "success",
text1: "设置成功",
@@ -196,7 +204,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
if (existingOutroStartTime) {
// Clear the time
set({ outroStartTime: undefined });
get()._savePlayRecord({ outroStartTime: undefined });
get()._savePlayRecord({ outroStartTime: undefined }, { immediate: true });
Toast.show({
type: "info",
text1: "已清除片尾时间",
@@ -206,7 +214,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
if (!status.durationMillis) return;
const newOutroStartTime = status.durationMillis - status.positionMillis;
set({ outroStartTime: newOutroStartTime });
get()._savePlayRecord({ outroStartTime: newOutroStartTime });
get()._savePlayRecord({ outroStartTime: newOutroStartTime }, { immediate: true });
Toast.show({
type: "success",
text1: "设置成功",
@@ -215,7 +223,18 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}
},
_savePlayRecord: (updates = {}) => {
_savePlayRecord: (updates = {}, options = {}) => {
const { immediate = false } = options;
if (!immediate) {
if (get()._isRecordSaveThrottled) {
return;
}
set({ _isRecordSaveThrottled: true });
setTimeout(() => {
set({ _isRecordSaveThrottled: false });
}, 10000); // 10 seconds
}
const { detail } = useDetailStore.getState();
const { currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
if (detail && status?.isLoaded) {

View File

@@ -56,9 +56,10 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
const config = await api.getServerConfig();
if (config) {
storageConfig.setStorageType(config.StorageType);
set({ serverConfig: config });
}
set({ serverConfig: config });
} catch (error) {
set({ serverConfig: null });
console.info("Failed to fetch server config:", error);
}
},

View File

@@ -4587,6 +4587,11 @@ expo-keep-awake@~13.0.2:
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz#5ef31311a339671eec9921b934fdd90ab9652b0e"
integrity sha512-kKiwkVg/bY0AJ5q1Pxnm/GvpeB6hbNJhcFsoOWDh2NlpibhCLaHL826KHUM+WsnJRbVRxJ+K9vbPRHEMvFpVyw==
expo-linear-gradient@~13.0.2:
version "13.0.2"
resolved "https://registry.yarnpkg.com/expo-linear-gradient/-/expo-linear-gradient-13.0.2.tgz#21bd7bc7c71ef4f7c089521daa16db729d2aec5f"
integrity sha512-EDcILUjRKu4P1rtWcwciN6CSyGtH7Bq4ll3oTRV7h3h8oSzSilH1g6z7kTAMlacPBKvMnkkWOGzW6KtgMKEiTg==
expo-linking@~6.3.1:
version "6.3.1"
resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-6.3.1.tgz#05aef8a42bd310391d0b00644be40d80ece038d9"
@@ -8804,7 +8809,16 @@ string-length@^5.0.1:
char-regex "^2.0.0"
strip-ansi "^7.0.1"
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8895,7 +8909,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -8909,6 +8923,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -9740,7 +9761,7 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -9758,6 +9779,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"