29 Commits

Author SHA1 Message Date
zimplexing
3fdd1fc587 feat: update screenshot assets with new images 2025-07-17 22:15:50 +08:00
zimplexing
4b3d1c620b chore: update version to 1.2.2 in package.json 2025-07-17 22:06:15 +08:00
zimplexing
1f694f9245 feat: add timeout handling for loading state with error notification 2025-07-17 22:04:28 +08:00
zimplexing
ec949029fa feat: enhance saveSettings function to process API base URL and ensure valid format 2025-07-17 21:14:03 +08:00
Xin
2325b76f77 Update README.md 2025-07-17 12:19:22 +08:00
Xin
4473fd6ab3 Update README.md 2025-07-16 23:09:12 +08:00
zimplexing
c514a6d03e chore: update version to 1.2.1 in package.json 2025-07-16 22:06:28 +08:00
zimplexing
f6baa0523c feat: enhance authentication flow by adding server configuration check and login handling in authStore 2025-07-16 22:06:04 +08:00
zimplexing
9540aaa3b9 chore: update version to 1.2.0 in package.json 2025-07-16 21:31:21 +08:00
Xin
8a1c26991b Merge pull request #38 from zimplexing/v1.2.0
Adapt moontv api
2025-07-16 21:29:51 +08:00
zimplexing
d83c4483ff refactor: remove padding and margin from searchButton for cleaner styling 2025-07-16 21:29:09 +08:00
zimplexing
9f4299004a refactor: comment out unused components in layout and detail screens for cleaner code 2025-07-16 21:28:12 +08:00
zimplexing
e0aa40eea0 feat: enhance PlayScreen and VideoCard with improved video loading and app state handling; update player store for better episode management 2025-07-16 21:26:37 +08:00
zimplexing
daba164998 refactor: update storage management to use centralized storage configuration and improve README documentation 2025-07-16 16:36:46 +08:00
zimplexing
57bc0b3582 feat: enhance PlayScreen and storage management with dynamic source handling and local storage support 2025-07-15 22:59:10 +08:00
zimplexing
0b1fa9df6d feat: enhance LoginModal with TV event handling and input focus management 2025-07-15 22:33:11 +08:00
zimplexing
d44e9fe9ae feat: enhance login status management and improve error logging across services 2025-07-15 21:41:38 +08:00
zimplexing
116cf12ca3 Merge remote-tracking branch 'origin/master' into v1.2.0 2025-07-15 18:54:04 +08:00
zimplexing
948368c3c8 feat: integrate settings and authentication stores for improved login status management 2025-07-15 18:53:57 +08:00
zimplexing
30cbf6846e feat: add favorites functionality and enhance detail screen 2025-07-15 17:07:53 +08:00
zimplexing
8985781865 refactor: enhance LoginModal and StyledButton components for improved functionality 2025-07-15 15:49:46 +08:00
zimplexing
bf99aee5f2 refactor: update play time property and enhance player settings management
- Changed the play time property from 'play_time' to 'time' in HomeScreen.
- Removed unused player control functions from PlayScreen.
- Added PlayerSettings interface and implemented PlayerSettingsManager for local storage of player settings.
- Refactored PlayRecordManager to merge API records with local player settings.
- Updated authentication logic in authStore to handle optional username parameter in login function.
- Cleaned up and optimized imports across various components.
2025-07-15 15:03:58 +08:00
Xin
bb9b8891c3 Update README.md 2025-07-15 14:18:27 +08:00
zimplexing
2bed3a4d00 feat: implement user authentication and logout functionality
- Added login/logout buttons to the HomeScreen and SettingsScreen.
- Integrated authentication state management using Zustand and cookies.
- Updated API to support username and password for login.
- Enhanced PlayScreen to handle video playback based on user authentication.
- Created a new detailStore to manage video details and sources.
- Refactored playerStore to utilize detailStore for episode management.
- Added sourceStore to manage video source toggling.
- Updated settingsStore to fetch server configuration.
- Improved error handling and user feedback with Toast notifications.
- Cleaned up unused code and optimized imports across components.
2025-07-14 22:55:55 +08:00
zimplexing
0452bfe21f feat: Implement user authentication and data management features
- Added LoginModal component for user login functionality.
- Introduced API routes for user login, favorites, play records, and search history management.
- Created JSON files for storing favorites, play records, and search history.
- Updated API service to handle new endpoints and refactored data management to use API calls instead of local storage.
- Adjusted data structures in types and services to align with new API responses.
2025-07-14 16:21:28 +08:00
zimplexing
f06b10feec fix: Update outro start time calculation to handle cases with undefined durationMillis 2025-07-14 13:51:25 +08:00
zimplexing
1c7c1cfd47 fix: Adjust jump position logic in PlayScreen to ensure a default value of 0 is used when both introEndTime and initialPosition are undefined 2025-07-14 13:43:54 +08:00
zimplexing
02eb19055b Update README.md to improve deployment instructions and add star history section 2025-07-14 13:39:54 +08:00
zimplexing
ee805960cc Add HTML charset 2025-07-14 13:31:02 +08:00
52 changed files with 2183 additions and 10177 deletions

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ expo-env.d.ts
web/**
.bmad-core
.kilocodemodes
.roomodes
.roomodes
yarn-errors.log

View File

@@ -18,10 +18,6 @@
- [Expo Router](https://docs.expo.dev/router/introduction/)
- [Expo AV](https://docs.expo.dev/versions/latest/sdk/av/)
- TypeScript
- **后端**:
- [Node.js](https://nodejs.org/)
- [Express](https://expressjs.com/)
- [TypeScript](https://www.typescriptlang.org/)
## 📂 项目结构
@@ -31,7 +27,6 @@
.
├── app/ # Expo Router 路由和页面
├── assets/ # 静态资源 (字体, 图片, TV 图标)
├── backend/ # 后端 Express 应用
├── components/ # React 组件
├── constants/ # 应用常量 (颜色, 样式)
├── hooks/ # 自定义 Hooks
@@ -52,24 +47,7 @@
- [Xcode](https://developer.apple.com/xcode/) (用于 Apple TV 开发)
- [Android Studio](https://developer.android.com/studio) (用于 Android TV 开发)
### 1. 后端服务
首先,启动后端服务:
```sh
# 进入后端目录
cd backend
# 安装依赖
yarn
# 启动开发服务器
yarn dev
```
后端服务将运行在 `http://localhost:3001`
### 2. 前端应用
### 项目启动
接下来,在项目根目录运行前端应用:
@@ -93,22 +71,16 @@ yarn android-tv
## 部署
### 后端部署
- 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 部署使用api 地址填部MoonTV署后的访问地址。
#### [Vercel](https://vercel.com/) 部署
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzimplexing%2FOrionTV&root-directory=backend)
#### Docker 部署
1. `docker pull zimpel1/tv-host`
2. `docker run -d -p 3001:3001 zimpel1/tv-host`
- **注意:** 地址后面不要带 `/` ,不要遗漏 `http://` 或者 `https://`
- 如果不想依赖 MoonTV可以使用 1.1.x 版本。
## 其他
- 最低版本是android 6.0,可用,但是不推荐
- 如果使用https的后端接口无法访问在确认服务没有问题的情况下请检查https的TLS协议Android 10 之后版本才支持 TLS1.3
- 最低版本是 android 6.0,可用,但是不推荐
- 如果使用 https 的后端接口无法访问,在确认服务没有问题的情况下,请检查 https 的 TLS 协议Android 10 之后版本才支持 TLS1.3
## 📜 主要脚本
@@ -122,9 +94,9 @@ yarn android-tv
## 📸 应用截图
![首页界面](screenshot/image.png)
![详情页面](screenshot/image1.png)
![搜索界面](screenshot/image3.png)
![视频播放](screenshot/image2.png)
![搜索界面](screenshot/image3.png)
![详情页面](screenshot/image1.png)
## 📝 License
@@ -136,9 +108,18 @@ OrionTV 仅作为视频搜索工具,不存储、上传或分发任何视频内
本项目开发者不对使用本项目产生的任何后果负责。使用本项目时,您必须遵守当地的法律法规。
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=zimplexing/OrionTV&type=Date)](https://www.star-history.com/#zimplexing/OrionTV&Date)
## 🙏 致谢
本项目受到以下开源项目的启发:
- [MoonTV](https://github.com/senshinya/MoonTV) - 一个基于 Next.js 的视频聚合应用
- [LibreTV](https://github.com/LibreSpark/LibreTV) - 一个开源的视频流媒体应用
感谢以下项目提供 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

@@ -8,7 +8,8 @@ import Toast from "react-native-toast-message";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { remoteControlService } from "@/services/remoteControlService";
import LoginModal from "@/components/LoginModal";
import useAuthStore from "@/stores/authStore";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -18,13 +19,20 @@ export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
const { loadSettings, remoteInputEnabled } = useSettingsStore();
const { loadSettings, remoteInputEnabled, apiBaseUrl } = useSettingsStore();
const { startServer, stopServer } = useRemoteControlStore();
const { checkLoginStatus } = useAuthStore();
useEffect(() => {
loadSettings();
}, [loadSettings]);
useEffect(() => {
if (apiBaseUrl) {
checkLoginStatus(apiBaseUrl);
}
}, [apiBaseUrl, checkLoginStatus]);
useEffect(() => {
if (loaded || error) {
SplashScreen.hideAsync();
@@ -55,9 +63,11 @@ 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="+not-found" />
</Stack>
<Toast />
<LoginModal />
</ThemeProvider>
);
}

View File

@@ -1,134 +1,49 @@
import React, { useEffect, useState, useRef } from "react";
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native";
import React, { useEffect } from "react";
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator, Pressable } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { api, SearchResult } from "@/services/api";
import { getResolutionFromM3U8 } from "@/services/m3u8";
import { StyledButton } from "@/components/StyledButton";
import { useSettingsStore } from "@/stores/settingsStore";
import useDetailStore from "@/stores/detailStore";
import { FontAwesome } from "@expo/vector-icons";
export default function DetailScreen() {
const { source, q } = useLocalSearchParams();
const { q, source, id } = useLocalSearchParams<{ q: string; source?: string; id?: string }>();
const router = useRouter();
const [searchResults, setSearchResults] = useState<(SearchResult & { resolution?: string | null })[]>([]);
const [detail, setDetail] = useState<(SearchResult & { resolution?: string | null }) | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [allSourcesLoaded, setAllSourcesLoaded] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const { videoSource } = useSettingsStore();
const {
detail,
searchResults,
loading,
error,
allSourcesLoaded,
init,
setDetail,
abort,
isFavorited,
toggleFavorite,
} = useDetailStore();
useEffect(() => {
if (controllerRef.current) {
controllerRef.current.abort();
if (q) {
init(q, source, id);
}
controllerRef.current = new AbortController();
const signal = controllerRef.current.signal;
if (typeof q === "string") {
const fetchDetailData = async () => {
setLoading(true);
setSearchResults([]);
setDetail(null);
setError(null);
setAllSourcesLoaded(false);
try {
const allResources = await api.getResources(signal);
if (!allResources || allResources.length === 0) {
setError("没有可用的播放源");
setLoading(false);
return;
}
// Filter resources based on enabled sources in settings
const resources = videoSource.enabledAll
? allResources
: allResources.filter((resource) => videoSource.sources[resource.key]);
if (!videoSource.enabledAll && resources.length === 0) {
setError("请到设置页面启用的播放源");
setLoading(false);
return;
}
let foundFirstResult = false;
// Prioritize source from params if available
if (typeof source === "string") {
const index = resources.findIndex((r) => r.key === source);
if (index > 0) {
resources.unshift(resources.splice(index, 1)[0]);
}
}
for (const resource of resources) {
try {
const { results } = await api.searchVideo(q, resource.key, signal);
if (results && results.length > 0) {
const searchResult = results[0];
let resolution;
try {
if (searchResult.episodes && searchResult.episodes.length > 0) {
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
}
} catch (e) {
if ((e as Error).name !== "AbortError") {
console.error(`Failed to get resolution for ${resource.name}`, e);
}
}
const resultWithResolution = { ...searchResult, resolution };
setSearchResults((prev) => [...prev, resultWithResolution]);
if (!foundFirstResult) {
setDetail(resultWithResolution);
foundFirstResult = true;
setLoading(false);
}
}
} catch (e) {
if ((e as Error).name !== "AbortError") {
console.error(`Error searching in resource ${resource.name}:`, e);
}
}
}
if (!foundFirstResult) {
setError("未找到播放源");
setLoading(false);
}
} catch (e) {
if ((e as Error).name !== "AbortError") {
setError(e instanceof Error ? e.message : "获取资源列表失败");
setLoading(false);
}
} finally {
setAllSourcesLoaded(true);
}
};
fetchDetailData();
}
return () => {
controllerRef.current?.abort();
abort();
};
}, [q, source, videoSource.enabledAll, videoSource.sources]);
}, [abort, init, q, source, id]);
const handlePlay = (episodeName: string, episodeIndex: number) => {
const handlePlay = (episodeIndex: number) => {
if (!detail) return;
controllerRef.current?.abort(); // Cancel any ongoing fetches
abort(); // Cancel any ongoing fetches
router.push({
pathname: "/play",
params: {
// Pass necessary identifiers, the rest will be in the store
q: detail.title,
source: detail.source,
id: detail.id.toString(),
episodeUrl: episodeName, // The "episode" is actually the URL
episodeIndex: episodeIndex.toString(),
title: detail.title,
poster: detail.poster,
},
});
};
@@ -172,6 +87,10 @@ export default function DetailScreen() {
<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>
@@ -217,7 +136,7 @@ export default function DetailScreen() {
<StyledButton
key={index}
style={styles.episodeButton}
onPress={() => handlePlay(episode, index)}
onPress={() => handlePlay(index)}
text={`${index + 1}`}
textStyle={styles.episodeButtonText}
/>
@@ -274,6 +193,19 @@ const styles = StyleSheet.create({
color: "#ccc",
lineHeight: 22,
},
favoriteButton: {
flexDirection: "row",
alignItems: "center",
marginTop: 10,
padding: 10,
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderRadius: 5,
alignSelf: "flex-start",
},
favoriteButtonText: {
marginLeft: 8,
fontSize: 16,
},
bottomContainer: {
paddingHorizontal: 20,
},

124
app/favorites.tsx Normal file
View File

@@ -0,0 +1,124 @@
import React, { useEffect } from "react";
import { View, FlatList, StyleSheet, ActivityIndicator, Image, Pressable } from "react-native";
import { useRouter } from "expo-router";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import useFavoritesStore from "@/stores/favoritesStore";
import { Favorite } from "@/services/storage";
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 },
});
};
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
data={favorites}
renderItem={renderItem}
keyExtractor={(item) => item.key}
numColumns={3}
contentContainerStyle={styles.list}
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 40,
},
headerContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 24,
marginBottom: 10,
},
headerTitle: {
fontSize: 32,
fontWeight: "bold",
paddingTop: 16,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
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

@@ -5,9 +5,10 @@ import { ThemedText } from "@/components/ThemedText";
import { api } from "@/services/api";
import VideoCard from "@/components/VideoCard.tv";
import { useFocusEffect, useRouter } from "expo-router";
import { Search, Settings } from "lucide-react-native";
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";
const NUM_COLUMNS = 5;
const { width } = Dimensions.get("window");
@@ -31,6 +32,7 @@ export default function HomeScreen() {
selectCategory,
refreshPlayRecords,
} = useHomeStore();
const { isLoggedIn, logout } = useAuthStore();
useFocusEffect(
useCallback(() => {
@@ -122,6 +124,9 @@ export default function HomeScreen() {
</Pressable>
</View>
<View style={styles.rightHeaderButtons}>
{/* <StyledButton style={styles.searchButton} onPress={() => router.push("/favorites")} variant="ghost">
<Heart color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton> */}
<StyledButton
style={styles.searchButton}
onPress={() => router.push({ pathname: "/search" })}
@@ -132,6 +137,11 @@ export default function HomeScreen() {
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
{isLoggedIn && (
<StyledButton style={styles.searchButton} onPress={logout} variant="ghost">
<LogOut color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
)}
</View>
</View>
@@ -236,9 +246,7 @@ const styles = StyleSheet.create({
alignItems: "center",
},
searchButton: {
padding: 10,
borderRadius: 30,
marginLeft: 10,
},
// Category Selector
categoryContainer: {

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react";
import { StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler } from "react-native";
import { StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler, AppState, AppStateStatus } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
@@ -10,50 +10,74 @@ import { SourceSelectionModal } from "@/components/SourceSelectionModal";
import { SeekingBar } from "@/components/SeekingBar";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import usePlayerStore from "@/stores/playerStore";
import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
import Toast from "react-native-toast-message";
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
export default function PlayScreen() {
const videoRef = useRef<Video>(null);
const router = useRouter();
useKeepAwake();
const { source, id, episodeIndex, position } = useLocalSearchParams<{
source: string;
id: string;
episodeIndex: string;
position: string;
}>();
const {
detail,
episodes,
currentEpisodeIndex,
episodeIndex: episodeIndexStr,
position: positionStr,
source: sourceStr,
id: videoId,
title: videoTitle,
} = useLocalSearchParams<{
episodeIndex: string;
position?: string;
source?: string;
id?: string;
title?: string;
}>();
const episodeIndex = parseInt(episodeIndexStr || "0", 10);
const position = positionStr ? parseInt(positionStr, 10) : undefined;
const { detail } = useDetailStore();
const source = sourceStr || detail?.source;
const id = videoId || detail?.id.toString();
const title = videoTitle || detail?.title;
const {
isLoading,
showControls,
showEpisodeModal,
showSourceModal,
showNextEpisodeOverlay,
initialPosition,
introEndTime,
setVideoRef,
loadVideo,
handlePlaybackStatusUpdate,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
setShowNextEpisodeOverlay,
reset,
loadVideo,
} = usePlayerStore();
const currentEpisode = usePlayerStore(selectCurrentEpisode);
useEffect(() => {
setVideoRef(videoRef);
if (source && id) {
loadVideo(source, id, parseInt(episodeIndex || "0", 10), parseInt(position || "0", 10));
if (source && id && title) {
loadVideo({ source, id, episodeIndex, position, title });
}
return () => {
reset(); // Reset state when component unmounts
};
}, [source, id, episodeIndex, position, setVideoRef, loadVideo, reset]);
}, [episodeIndex, source, position, setVideoRef, reset, loadVideo, id, title]);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === "background" || nextAppState === "inactive") {
videoRef.current?.pauseAsync();
}
};
const subscription = AppState.addEventListener("change", handleAppStateChange);
return () => {
subscription.remove();
};
}, []);
const { onScreenPress } = useTVRemoteHandler();
@@ -70,17 +94,28 @@ export default function PlayScreen() {
const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction);
return () => backHandler.remove();
}, [
showControls,
showEpisodeModal,
showSourceModal,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
router,
]);
}, [showControls, setShowControls, router]);
if (!detail && isLoading) {
useEffect(() => {
let timeoutId: NodeJS.Timeout | null = null;
if (isLoading) {
timeoutId = setTimeout(() => {
if (usePlayerStore.getState().isLoading) {
usePlayerStore.setState({ isLoading: false });
Toast.show({ type: "error", text1: "播放超时,请重试" });
}
}, 60000); // 1 minute
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [isLoading]);
if (!detail) {
return (
<ThemedView style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#fff" />
@@ -88,21 +123,19 @@ export default function PlayScreen() {
);
}
const currentEpisode = episodes[currentEpisodeIndex];
return (
<ThemedView focusable style={styles.container}>
<TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}>
<Video
ref={videoRef}
style={styles.videoPlayer}
source={{ uri: currentEpisode?.url }}
source={{ uri: currentEpisode?.url || "" }}
usePoster
posterSource={{ uri: detail?.videoInfo.cover ?? "" }}
posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => {
const jumpPosition = introEndTime || initialPosition;
const jumpPosition = initialPosition || introEndTime || 0;
if (jumpPosition > 0) {
videoRef.current?.setPositionAsync(jumpPosition);
}

View File

@@ -59,7 +59,7 @@ export default function SearchScreen() {
}
} catch (err) {
setError("搜索失败,请稍后重试。");
console.error("Search failed:", err);
console.info("Search failed:", err);
} finally {
setLoading(false);
}

View File

@@ -6,6 +6,7 @@ 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 { useRemoteControlStore } from "@/stores/remoteControlStore";
import { APIConfigSection } from "@/components/settings/APIConfigSection";
import { LiveStreamSection } from "@/components/settings/LiveStreamSection";
@@ -16,6 +17,7 @@ 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);

View File

@@ -3,8 +3,7 @@
"api_site": {
"dyttzy": {
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
"name": "电影天堂资源",
"detail": "http://caiji.dyttzyapi.com"
"name": "电影天堂资源"
},
"ruyi": {
"api": "https://cj.rycjapi.com/api.php/provide/vod",
@@ -16,8 +15,7 @@
},
"heimuer": {
"api": "https://json.heimuer.xyz/api.php/provide/vod",
"name": "黑木耳",
"detail": "https://heimuer.tv"
"name": "黑木耳"
},
"bfzy": {
"api": "https://bfzyapi.com/api.php/provide/vod",
@@ -29,8 +27,7 @@
},
"ffzy": {
"api": "http://ffzy5.tv/api.php/provide/vod",
"name": "非凡影视",
"detail": "http://ffzy5.tv"
"name": "非凡影视"
},
"zy360": {
"api": "https://360zy.com/api.php/provide/vod",
@@ -50,8 +47,7 @@
},
"jisu": {
"api": "https://jszyapi.com/api.php/provide/vod",
"name": "极速资源",
"detail": "https://jszyapi.com"
"name": "极速资源"
},
"dbzy": {
"api": "https://dbzy.tv/api.php/provide/vod",

View File

@@ -54,7 +54,7 @@ let cachedConfig: Config;
try {
cachedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as Config;
} catch (error) {
console.error(`Error reading or parsing config.json at ${configPath}`, error);
console.info(`Error reading or parsing config.json at ${configPath}`, error);
// Provide a default fallback config to prevent crashes
cachedConfig = {
api_site: {},

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,7 @@ const router = Router();
// Match m3u8 links
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
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);
@@ -30,8 +27,7 @@ async function handleSpecialSourceDetail(
let matches: string[] = [];
if (apiSite.key === "ffzy") {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
@@ -48,32 +44,22 @@ async function handleSpecialSourceDetail(
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : "";
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
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 {
code: 200,
episodes: matches,
detailUrl,
videoInfo: {
title: titleText,
cover: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id,
},
id,
title: titleText,
poster: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
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);
@@ -89,12 +75,7 @@ async function getDetailFromApi(
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
throw new Error("获取到的详情内容无效");
}
@@ -111,10 +92,7 @@ async function getDetailFromApi(
const parts = ep.split("$");
return parts.length > 1 ? parts[1] : "";
})
.filter(
(url: string) =>
url && (url.startsWith("http://") || url.startsWith("https://"))
);
.filter((url: string) => url && (url.startsWith("http://") || url.startsWith("https://")));
}
}
@@ -124,30 +102,22 @@ async function getDetailFromApi(
}
return {
code: 200,
episodes,
detailUrl,
videoInfo: {
title: videoDetail.vod_name,
cover: 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,
id,
},
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> {
async function getVideoDetail(id: string, sourceCode: string): Promise<VideoDetail> {
if (!id) {
throw new Error("缺少视频ID参数");
}

View File

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

@@ -35,7 +35,7 @@ router.get("/", async (req: Request, res: Response) => {
res.status(500).send("Image response has no body");
}
} catch (error) {
console.error("Image proxy error:", error);
console.info("Image proxy error:", error);
res.status(500).send("Error fetching image");
}
});

View File

@@ -3,9 +3,19 @@ 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);

View File

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

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

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

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

@@ -2,7 +2,7 @@
export interface PlayRecord {
title: string;
source_name: string;
cover: string;
poster: string;
index: number; // Episode number
total_episodes: number; // Total number of episodes
play_time: number; // Play progress in seconds
@@ -13,21 +13,16 @@ export interface PlayRecord {
// You can add other shared types here
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover: string;
desc: string;
source_name: string;
source: string;
id: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
};
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;
}

174
components/LoginModal.tsx Normal file
View File

@@ -0,0 +1,174 @@
import React, { useState, useRef, useEffect } from "react";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, useTVEventHandler } from "react-native";
import Toast from "react-native-toast-message";
import useAuthStore from "@/stores/authStore";
import { useSettingsStore } from "@/stores/settingsStore";
import useHomeStore from "@/stores/homeStore";
import { api } from "@/services/api";
import { ThemedView } from "./ThemedView";
import { ThemedText } from "./ThemedText";
import { StyledButton } from "./StyledButton";
const LoginModal = () => {
const { isLoginModalVisible, hideLoginModal, checkLoginStatus } = useAuthStore();
const { serverConfig, apiBaseUrl } = useSettingsStore();
const { refreshPlayRecords } = useHomeStore();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
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);
useEffect(() => {
if (isLoginModalVisible) {
const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
setTimeout(() => {
if (isUsernameVisible) {
usernameInputRef.current?.focus();
} else {
passwordInputRef.current?.focus();
}
}, 200);
}
}, [isLoginModalVisible, serverConfig]);
const handleLogin = async () => {
const isLocalStorage = serverConfig?.StorageType === "localstorage";
if (!password || (!isLocalStorage && !username)) {
Toast.show({ type: "error", text1: "请输入用户名和密码" });
return;
}
setIsLoading(true);
try {
await api.login(isLocalStorage ? undefined : username, password);
await checkLoginStatus(apiBaseUrl);
await refreshPlayRecords();
Toast.show({ type: "success", text1: "登录成功" });
hideLoginModal();
setUsername("");
setPassword("");
} catch {
Toast.show({ type: "error", text1: "登录失败", text2: "用户名或密码错误" });
} finally {
setIsLoading(false);
}
};
return (
<Modal transparent={true} visible={isLoginModalVisible} animationType="fade" onRequestClose={hideLoginModal}>
<View style={styles.overlay}>
<ThemedView style={styles.container}>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.subtitle}></ThemedText>
{serverConfig?.StorageType !== "localstorage" && (
<TextInput
ref={usernameInputRef}
style={styles.input}
placeholder="请输入用户名"
placeholderTextColor="#888"
value={username}
onChangeText={setUsername}
returnKeyType="next"
onFocus={() => setFocused("username")}
/>
)}
<TextInput
ref={passwordInputRef}
style={styles.input}
placeholder="请输入密码"
placeholderTextColor="#888"
secureTextEntry
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}
>
{isLoading && <ActivityIndicator color="#fff" />}
</StyledButton>
</ThemedView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
},
container: {
width: "80%",
maxWidth: 400,
padding: 24,
borderRadius: 12,
alignItems: "center",
},
title: {
fontSize: 22,
fontWeight: "bold",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#ccc",
marginBottom: 20,
textAlign: "center",
},
input: {
width: "100%",
height: 50,
backgroundColor: "#333",
borderRadius: 8,
paddingHorizontal: 16,
color: "#fff",
fontSize: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: "#555",
},
button: {
width: "100%",
height: 50,
},
});
export default LoginModal;

View File

@@ -5,6 +5,8 @@ import { ThemedText } from "@/components/ThemedText";
import { MediaButton } from "@/components/MediaButton";
import usePlayerStore from "@/stores/playerStore";
import useDetailStore from "@/stores/detailStore";
import { useSources } from "@/stores/sourceStore";
interface PlayerControlsProps {
showControls: boolean;
@@ -13,9 +15,8 @@ interface PlayerControlsProps {
export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => {
const {
detail,
currentEpisodeIndex,
currentSourceIndex,
episodes,
status,
isSeeking,
seekPosition,
@@ -30,12 +31,15 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
outroStartTime,
} = usePlayerStore();
const videoTitle = detail?.videoInfo?.title || "";
const currentEpisode = detail?.episodes[currentEpisodeIndex];
const { detail } = useDetailStore();
const resources = useSources();
const videoTitle = detail?.title || "";
const currentEpisode = episodes[currentEpisodeIndex];
const currentEpisodeTitle = currentEpisode?.title;
const currentSource = detail?.sources[currentSourceIndex];
const currentSource = resources.find((r) => r.source === detail?.source);
const currentSourceName = currentSource?.source_name;
const hasNextEpisode = currentEpisodeIndex < (detail?.episodes.length || 0) - 1;
const hasNextEpisode = currentEpisodeIndex < (episodes.length || 0) - 1;
const formatTime = (milliseconds: number) => {
if (!milliseconds) return "00:00";

View File

@@ -1,14 +1,16 @@
import React from "react";
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
import { StyledButton } from "./StyledButton";
import useDetailStore from "@/stores/detailStore";
import usePlayerStore from "@/stores/playerStore";
export const SourceSelectionModal: React.FC = () => {
const { showSourceModal, sources, currentSourceIndex, switchSource, setShowSourceModal } = usePlayerStore();
const { showSourceModal, setShowSourceModal } = usePlayerStore();
const { searchResults, detail, setDetail } = useDetailStore();
const onSelectSource = (index: number) => {
if (index !== currentSourceIndex) {
switchSource(index);
if (searchResults[index].source !== detail?.source) {
setDetail(searchResults[index]);
}
setShowSourceModal(false);
};
@@ -23,16 +25,16 @@ export const SourceSelectionModal: React.FC = () => {
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
<FlatList
data={sources}
data={searchResults}
numColumns={3}
contentContainerStyle={styles.sourceList}
keyExtractor={(item, index) => `source-${item.source}-${item.id}-${index}`}
keyExtractor={(item, index) => `source-${item.source}-${index}`}
renderItem={({ item, index }) => (
<StyledButton
text={item.source_name}
onPress={() => onSelectSource(index)}
isSelected={currentSourceIndex === index}
hasTVPreferredFocus={currentSourceIndex === index}
isSelected={detail?.source === item.source}
hasTVPreferredFocus={detail?.source === item.source}
style={styles.sourceItem}
textStyle={styles.sourceItemText}
/>

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle } from "react-native";
import React, { forwardRef } from "react";
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle, View } from "react-native";
import { ThemedText } from "./ThemedText";
import { Colors } from "@/constants/Colors";
import { useButtonAnimation } from "@/hooks/useAnimation";
@@ -13,133 +13,130 @@ interface StyledButtonProps extends PressableProps {
textStyle?: StyleProp<TextStyle>;
}
export const StyledButton: React.FC<StyledButtonProps> = ({
children,
text,
variant = "default",
isSelected = false,
style,
textStyle,
...rest
}) => {
const colorScheme = "dark";
const colors = Colors[colorScheme];
const [isFocused, setIsFocused] = React.useState(false);
const animationStyle = useButtonAnimation(isFocused);
export const StyledButton = forwardRef<View, StyledButtonProps>(
({ children, text, variant = "default", isSelected = false, style, textStyle, ...rest }, ref) => {
const colorScheme = "dark";
const colors = Colors[colorScheme];
const [isFocused, setIsFocused] = React.useState(false);
const animationStyle = useButtonAnimation(isFocused);
const variantStyles = {
default: StyleSheet.create({
const variantStyles = {
default: StyleSheet.create({
button: {
backgroundColor: colors.border,
},
text: {
color: colors.text,
},
selectedButton: {
backgroundColor: colors.tint,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
},
selectedText: {
color: Colors.dark.text,
},
}),
primary: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
},
selectedButton: {
backgroundColor: "rgba(0, 122, 255, 0.3)",
},
selectedText: {
color: colors.link,
},
}),
ghost: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
},
selectedButton: {},
selectedText: {},
}),
};
const styles = StyleSheet.create({
button: {
backgroundColor: colors.border,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
borderWidth: 2,
borderColor: "transparent",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
text: {
color: colors.text,
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
elevation: 5,
shadowColor: colors.link,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 15,
},
selectedButton: {
backgroundColor: colors.tint,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
text: {
fontSize: 16,
fontWeight: "500",
color: colors.text,
},
selectedText: {
color: Colors.dark.text,
},
}),
primary: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
},
selectedButton: {
backgroundColor: "rgba(0, 122, 255, 0.3)",
},
selectedText: {
color: colors.link,
},
}),
ghost: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
},
selectedButton: {},
selectedText: {},
}),
};
});
const styles = StyleSheet.create({
button: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
borderWidth: 2,
borderColor: "transparent",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
elevation: 5,
shadowColor: colors.link,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 15,
},
selectedButton: {
backgroundColor: colors.tint,
},
text: {
fontSize: 16,
fontWeight: "500",
color: colors.text,
},
selectedText: {
color: Colors.dark.text,
},
});
return (
<Animated.View style={[animationStyle, style]}>
<Pressable
ref={ref}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
style={({ focused }) => [
styles.button,
variantStyles[variant].button,
isSelected && (variantStyles[variant].selectedButton ?? styles.selectedButton),
focused && (variantStyles[variant].focusedButton ?? styles.focusedButton),
]}
{...rest}
>
{text ? (
<ThemedText
style={[
styles.text,
variantStyles[variant].text,
isSelected && (variantStyles[variant].selectedText ?? styles.selectedText),
textStyle,
]}
>
{text}
</ThemedText>
) : (
children
)}
</Pressable>
</Animated.View>
);
}
);
return (
<Animated.View style={[animationStyle, style]}>
<Pressable
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
style={({ focused }) => [
styles.button,
variantStyles[variant].button,
isSelected && (variantStyles[variant].selectedButton ?? styles.selectedButton),
focused && (variantStyles[variant].focusedButton ?? styles.focusedButton),
]}
{...rest}
>
{text ? (
<ThemedText
style={[
styles.text,
variantStyles[variant].text,
isSelected && (variantStyles[variant].selectedText ?? styles.selectedText),
textStyle,
]}
>
{text}
</ThemedText>
) : (
children
)}
</Pressable>
</Animated.View>
);
};
StyledButton.displayName = "StyledButton";

View File

@@ -34,11 +34,10 @@ export default function VideoCard({
sourceName,
progress,
episodeIndex,
totalEpisodes,
onFocus,
onRecordDeleted,
api,
playTime,
playTime = 0,
}: VideoCardProps) {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
@@ -62,7 +61,7 @@ export default function VideoCard({
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex, position: playTime },
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({
@@ -112,7 +111,7 @@ export default function VideoCard({
router.replace("/");
}
} catch (error) {
console.error("Failed to delete play record:", error);
console.info("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
},

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect, useCallback } from "react";
import { StyleSheet, View, Switch, ActivityIndicator, FlatList, Pressable, Animated } from "react-native";
import React, { useState, useCallback } from "react";
import { StyleSheet, Switch, FlatList, Pressable, Animated } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection";
import { api, ApiSite } from "@/services/api";
import { useSettingsStore } from "@/stores/settingsStore";
import useSourceStore, { useSources } from "@/stores/sourceStore";
interface VideoSourceSectionProps {
onChanged: () => void;
@@ -13,56 +13,18 @@ interface VideoSourceSectionProps {
}
export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChanged, onFocus, onBlur }) => {
const [resources, setResources] = useState<ApiSite[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [isSectionFocused, setIsSectionFocused] = useState(false);
const { videoSource, setVideoSource } = useSettingsStore();
const { videoSource } = useSettingsStore();
const resources = useSources();
const { toggleResourceEnabled } = useSourceStore();
useEffect(() => {
fetchResources();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchResources = async () => {
try {
setLoading(true);
const resourcesList = await api.getResources();
setResources(resourcesList);
if (videoSource.enabledAll && Object.keys(videoSource.sources).length === 0) {
const allResourceKeys: { [key: string]: boolean } = {};
for (const resource of resourcesList) {
allResourceKeys[resource.key] = true;
}
setVideoSource({
enabledAll: true,
sources: allResourceKeys,
});
}
} catch (err) {
setError("获取播放源失败");
console.error("Failed to fetch resources:", err);
} finally {
setLoading(false);
}
};
const toggleResourceEnabled = useCallback(
const handleToggle = useCallback(
(resourceKey: string) => {
const isEnabled = videoSource.sources[resourceKey];
const newEnabledSources = { ...videoSource.sources, [resourceKey]: !isEnabled };
setVideoSource({
enabledAll: Object.values(newEnabledSources).every((enabled) => enabled),
sources: newEnabledSources,
});
toggleResourceEnabled(resourceKey);
onChanged();
},
[videoSource.sources, setVideoSource, onChanged]
[onChanged, toggleResourceEnabled]
);
const handleSectionFocus = () => {
@@ -83,20 +45,20 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
if (focusedIndex !== null) {
const resource = resources[focusedIndex];
if (resource) {
toggleResourceEnabled(resource.key);
handleToggle(resource.source);
}
} else if (isSectionFocused) {
setFocusedIndex(0);
}
}
},
[isSectionFocused, focusedIndex, resources, toggleResourceEnabled]
[isSectionFocused, focusedIndex, resources, handleToggle]
);
useTVEventHandler(handleTVEvent);
const renderResourceItem = ({ item, index }: { item: ApiSite; index: number }) => {
const isEnabled = videoSource.enabledAll || videoSource.sources[item.key];
const renderResourceItem = ({ item, index }: { item: { source: string; source_name: string }; index: number }) => {
const isEnabled = videoSource.enabledAll || videoSource.sources[item.source];
const isFocused = focusedIndex === index;
return (
@@ -107,7 +69,7 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
onFocus={() => setFocusedIndex(index)}
onBlur={() => setFocusedIndex(null)}
>
<ThemedText style={styles.resourceName}>{item.name}</ThemedText>
<ThemedText style={styles.resourceName}>{item.source_name}</ThemedText>
<Switch
value={isEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互
@@ -124,20 +86,11 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<ThemedText style={styles.sectionTitle}></ThemedText>
{loading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" />
<ThemedText style={styles.loadingText}>...</ThemedText>
</View>
)}
{error && <ThemedText style={styles.errorText}>{error}</ThemedText>}
{!loading && !error && resources.length > 0 && (
{resources.length > 0 && (
<FlatList
data={resources}
renderItem={renderResourceItem}
keyExtractor={(item) => item.key}
keyExtractor={(item) => item.source}
numColumns={3}
columnWrapperStyle={styles.row}
contentContainerStyle={styles.flatListContainer}
@@ -154,22 +107,6 @@ const styles = StyleSheet.create({
fontWeight: "bold",
marginBottom: 16,
},
loadingContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: 16,
},
loadingText: {
marginLeft: 8,
color: "#888",
},
errorText: {
color: "#ff4444",
fontSize: 14,
textAlign: "center",
padding: 16,
},
flatListContainer: {
gap: 12,
},

313
docs/API.md Normal file
View File

@@ -0,0 +1,313 @@
### 服务器配置
- **接口地址**: `/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,229 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { useLocalSearchParams } from "expo-router";
import { Video, AVPlaybackStatus } from "expo-av";
import { api, VideoDetail } from "@/services/api";
import { PlayRecordManager } from "@/services/storage";
import { getResolutionFromM3U8 } from "@/services/m3u8";
interface Episode {
title?: string;
url: string;
}
interface Source {
name?: string;
url: string;
}
export const usePlaybackManager = (videoRef: React.RefObject<Video>) => {
const params = useLocalSearchParams();
const [detail, setDetail] = useState<VideoDetail | null>(null);
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(
params.episodeIndex ? parseInt(params.episodeIndex as string) : 0
);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
const [resolution, setResolution] = useState<string | null>(null);
const [status, setStatus] = useState<AVPlaybackStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [initialSeekApplied, setInitialSeekApplied] = useState(false);
const [showNextEpisodeOverlay, setShowNextEpisodeOverlay] = useState(false);
const autoPlayTimer = useRef<NodeJS.Timeout | null>(null);
const saveRecordTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
fetchVideoDetail();
saveRecordTimer.current = setInterval(() => {
saveCurrentPlayRecord();
}, 30000);
return () => {
saveCurrentPlayRecord();
if (saveRecordTimer.current) {
clearInterval(saveRecordTimer.current);
}
if (autoPlayTimer.current) {
clearTimeout(autoPlayTimer.current);
}
};
}, []);
useEffect(() => {
if (status?.isLoaded && "isPlaying" in status && !status.isPlaying) {
saveCurrentPlayRecord();
}
}, [status]);
useEffect(() => {
if (!detail || !videoRef.current || initialSeekApplied) return;
loadPlayRecord();
}, [detail, currentEpisodeIndex, videoRef.current]);
const fetchVideoDetail = async () => {
try {
setIsLoading(true);
const source = (params.source as string) || "1";
const id = (params.id as string) || "1";
const data = await api.getVideoDetail(source, id);
setDetail(data);
const processedEpisodes = data.episodes.map((url, index) => ({
title: `${index + 1}`,
url,
}));
setEpisodes(processedEpisodes);
if (data.episodes.length > 0) {
const demoSources = [
{ name: "默认线路", url: data.episodes[0] },
{ name: "备用线路", url: data.episodes[0] },
];
setSources(demoSources);
}
} catch (error) {
console.error("Error fetching video detail:", error);
} finally {
setIsLoading(false);
}
};
const loadPlayRecord = async () => {
if (typeof params.source !== "string" || typeof params.id !== "string")
return;
try {
const record = await PlayRecordManager.get(params.source, params.id);
if (record && videoRef.current && record.index === currentEpisodeIndex) {
setTimeout(async () => {
if (videoRef.current) {
await videoRef.current.setPositionAsync(record.play_time * 1000);
setInitialSeekApplied(true);
}
}, 2000);
}
} catch (error) {
console.error("Error loading play record:", error);
}
};
const saveCurrentPlayRecord = async () => {
if (!status?.isLoaded || !detail?.videoInfo) return;
const { source, id } = params;
if (typeof source !== "string" || typeof id !== "string") return;
try {
await PlayRecordManager.save(source, id, {
title: detail.videoInfo.title,
source_name: detail.videoInfo.source_name,
cover: detail.videoInfo.cover || "",
index: currentEpisodeIndex,
total_episodes: episodes.length,
play_time: Math.floor(status.positionMillis / 1000),
total_time: Math.floor((status.durationMillis || 0) / 1000),
});
} catch (error) {
console.error("Failed to save play record:", error);
}
};
const playEpisode = async (episodeIndex: number) => {
if (autoPlayTimer.current) {
clearTimeout(autoPlayTimer.current);
autoPlayTimer.current = null;
}
setShowNextEpisodeOverlay(false);
setCurrentEpisodeIndex(episodeIndex);
setIsLoading(true);
setInitialSeekApplied(false);
setResolution(null); // Reset resolution
if (videoRef.current && episodes[episodeIndex]) {
const episodeUrl = episodes[episodeIndex].url;
getResolutionFromM3U8(episodeUrl).then(setResolution);
await videoRef.current.unloadAsync();
setTimeout(async () => {
try {
await videoRef.current?.loadAsync(
{ uri: episodeUrl },
{ shouldPlay: true }
);
} catch (error) {
console.error("Error loading video:", error);
} finally {
setIsLoading(false);
}
}, 200);
}
};
const playNextEpisode = () => {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
}
};
const togglePlayPause = async () => {
if (!videoRef.current) return;
if (status?.isLoaded && status.isPlaying) {
await videoRef.current.pauseAsync();
} else {
await videoRef.current.playAsync();
}
};
const seek = async (forward: boolean) => {
if (!videoRef.current || !status?.isLoaded) return;
const wasPlaying = status.isPlaying;
const seekTime = forward ? 10000 : -10000;
const position = status.positionMillis + seekTime;
await videoRef.current.setPositionAsync(Math.max(0, position));
if (wasPlaying) {
await videoRef.current.playAsync();
}
};
const handlePlaybackStatusUpdate = (newStatus: AVPlaybackStatus) => {
setStatus(newStatus);
if (newStatus.isLoaded) {
if (
newStatus.durationMillis &&
newStatus.positionMillis &&
newStatus.durationMillis - newStatus.positionMillis < 2000 &&
currentEpisodeIndex < episodes.length - 1 &&
!showNextEpisodeOverlay
) {
setShowNextEpisodeOverlay(true);
if (autoPlayTimer.current) clearTimeout(autoPlayTimer.current);
autoPlayTimer.current = setTimeout(() => {
playNextEpisode();
}, 2000);
}
}
};
return {
detail,
episodes,
sources,
currentEpisodeIndex,
currentSourceIndex,
status,
isLoading,
showNextEpisodeOverlay,
resolution,
setCurrentSourceIndex,
setStatus,
setShowNextEpisodeOverlay,
setIsLoading,
playEpisode,
playNextEpisode,
togglePlayPause,
seek,
handlePlaybackStatusUpdate,
};
};

View File

@@ -2,7 +2,7 @@
"name": "OrionTV",
"private": true,
"main": "expo-router/entry",
"version": "1.1.2",
"version": "1.2.2",
"scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
@@ -29,6 +29,7 @@
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^11.3.2",
"@react-native-cookies/cookies": "^6.2.1",
"@react-navigation/native": "^6.0.2",
"expo": "~51.0.13",
"expo-av": "~14.0.7",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 672 KiB

View File

@@ -1,5 +1,5 @@
import { SettingsManager } from "./storage";
// region: --- Interface Definitions ---
export interface DoubanItem {
title: string;
poster: string;
@@ -13,23 +13,18 @@ export interface DoubanResponse {
}
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover?: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
source_name: string;
source: string;
id: string;
};
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;
}
export interface SearchResult {
@@ -45,17 +40,27 @@ export interface SearchResult {
type_name?: string;
}
// Data structure for play records
export interface Favorite {
cover: string;
title: string;
poster: string;
source_name: string;
total_episodes: number;
search_title: string;
year: string;
save_time?: number;
}
export interface PlayRecord {
title: string;
source_name: string;
cover: 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
index: number;
total_episodes: number;
play_time: number;
total_time: number;
save_time: number;
year: string;
}
export interface ApiSite {
@@ -65,6 +70,11 @@ export interface ApiSite {
detail?: string;
}
export interface ServerConfig {
SiteName: string;
StorageType: "localstorage" | "redis" | string;
}
export class API {
public baseURL: string = "";
@@ -78,91 +88,138 @@ export class API {
this.baseURL = url;
}
/**
* 生成图片代理 URL
*/
getImageProxyUrl(imageUrl: string): string {
return `${this.baseURL}/api/image-proxy?url=${encodeURIComponent(
imageUrl
)}`;
private async _fetch(url: string, options: RequestInit = {}): Promise<Response> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const response = await fetch(`${this.baseURL}${url}`, options);
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
}
async getServerConfig(): Promise<ServerConfig> {
const response = await this._fetch("/api/server-config");
return response.json();
}
async login(username?: string | undefined, password?: string): Promise<{ ok: boolean }> {
const response = await this._fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return response.json();
}
async getFavorites(key?: string): Promise<Record<string, Favorite> | Favorite | null> {
const url = key ? `/api/favorites?key=${encodeURIComponent(key)}` : "/api/favorites";
const response = await this._fetch(url);
return response.json();
}
async addFavorite(key: string, favorite: Omit<Favorite, "save_time">): Promise<{ success: boolean }> {
const response = await this._fetch("/api/favorites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, favorite }),
});
return response.json();
}
async deleteFavorite(key?: string): Promise<{ success: boolean }> {
const url = key ? `/api/favorites?key=${encodeURIComponent(key)}` : "/api/favorites";
const response = await this._fetch(url, { method: "DELETE" });
return response.json();
}
async getPlayRecords(): Promise<Record<string, PlayRecord>> {
const response = await this._fetch("/api/playrecords");
return response.json();
}
async savePlayRecord(key: string, record: Omit<PlayRecord, "save_time">): Promise<{ success: boolean }> {
const response = await this._fetch("/api/playrecords", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, record }),
});
return response.json();
}
async deletePlayRecord(key?: string): Promise<{ success: boolean }> {
const url = key ? `/api/playrecords?key=${encodeURIComponent(key)}` : "/api/playrecords";
const response = await this._fetch(url, { method: "DELETE" });
return response.json();
}
async getSearchHistory(): Promise<string[]> {
const response = await this._fetch("/api/searchhistory");
return response.json();
}
async addSearchHistory(keyword: string): Promise<string[]> {
const response = await this._fetch("/api/searchhistory", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyword }),
});
return response.json();
}
async deleteSearchHistory(keyword?: string): Promise<{ success: boolean }> {
const url = keyword ? `/api/searchhistory?keyword=${keyword}` : "/api/searchhistory";
const response = await this._fetch(url, { method: "DELETE" });
return response.json();
}
getImageProxyUrl(imageUrl: string): string {
return `${this.baseURL}/api/image-proxy?url=${encodeURIComponent(imageUrl)}`;
}
/**
* 获取豆瓣数据
*/
async getDoubanData(
type: "movie" | "tv",
tag: string,
pageSize: number = 16,
pageStart: number = 0
): Promise<DoubanResponse> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${
this.baseURL
}/api/douban?type=${type}&tag=${encodeURIComponent(
tag
)}&pageSize=${pageSize}&pageStart=${pageStart}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/douban?type=${type}&tag=${encodeURIComponent(tag)}&pageSize=${pageSize}&pageStart=${pageStart}`;
const response = await this._fetch(url);
return response.json();
}
/**
* 搜索视频
*/
async searchVideos(query: string): Promise<{ results: SearchResult[] }> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${this.baseURL}/api/search?q=${encodeURIComponent(query)}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/search?q=${encodeURIComponent(query)}`;
const response = await this._fetch(url);
return response.json();
}
async searchVideo(query: string, resourceId: string, signal?: AbortSignal): Promise<{ results: SearchResult[] }> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${this.baseURL}/api/search/one?q=${encodeURIComponent(query)}&resourceId=${encodeURIComponent(resourceId)}`;
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/search/one?q=${encodeURIComponent(query)}&resourceId=${encodeURIComponent(resourceId)}`;
const response = await this._fetch(url, { signal });
return response.json();
}
async getResources(signal?: AbortSignal): Promise<ApiSite[]> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${this.baseURL}/api/search/resources`;
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/search/resources`;
const response = await this._fetch(url, { signal });
return response.json();
}
/**
* 获取视频详情
*/
async getVideoDetail(source: string, id: string): Promise<VideoDetail> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${this.baseURL}/api/detail?source=${source}&id=${id}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/detail?source=${source}&id=${id}`;
const response = await this._fetch(url);
return response.json();
}
}
// 默认实例
export let api = new API();
// 初始化 API
export const initializeApi = async () => {
const settings = await SettingsManager.get();
api.setBaseUrl(settings.apiBaseUrl);
};

View File

@@ -47,7 +47,7 @@ export const fetchAndParseM3u = async (m3uUrl: string): Promise<Channel[]> => {
const m3uText = await response.text();
return parseM3U(m3uText);
} catch (error) {
console.error("Error fetching or parsing M3U:", error);
console.info("Error fetching or parsing M3U:", error);
return []; // Return empty array on error
}
};

View File

@@ -1,4 +1,4 @@
import TCPHttpServer from './tcpHttpServer';
import TCPHttpServer from "./tcpHttpServer";
const getRemotePageHTML = () => {
return `
@@ -6,6 +6,7 @@ const getRemotePageHTML = () => {
<html>
<head>
<title>OrionTV Remote</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #121212; color: white; }
@@ -24,7 +25,7 @@ const getRemotePageHTML = () => {
</div>
<script>
window.addEventListener('DOMContentLoaded', () => {
fetch('/handshake', { method: 'POST' }).catch(console.error);
fetch('/handshake', { method: 'POST' }).catch(console.info);
});
function send() {
const input = document.getElementById("text");
@@ -35,7 +36,7 @@ const getRemotePageHTML = () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value })
})
.catch(err => console.error(err));
.catch(err => console.info(err));
input.value = '';
}
}
@@ -57,55 +58,55 @@ class RemoteControlService {
private setupRequestHandler() {
this.httpServer.setRequestHandler((request) => {
console.log('[RemoteControl] Received request:', request.method, request.url);
console.log("[RemoteControl] Received request:", request.method, request.url);
try {
if (request.method === 'GET' && request.url === '/') {
if (request.method === "GET" && request.url === "/") {
return {
statusCode: 200,
headers: { 'Content-Type': 'text/html' },
body: getRemotePageHTML()
headers: { "Content-Type": "text/html; charset=utf-8" },
body: getRemotePageHTML(),
};
} else if (request.method === 'POST' && request.url === '/message') {
} else if (request.method === "POST" && request.url === "/message") {
try {
const parsedBody = JSON.parse(request.body || '{}');
const parsedBody = JSON.parse(request.body || "{}");
const message = parsedBody.message;
if (message) {
this.onMessage(message);
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'ok' })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ok" }),
};
} catch (parseError) {
console.error('[RemoteControl] Failed to parse message body:', parseError);
console.info("[RemoteControl] Failed to parse message body:", parseError);
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Invalid JSON' })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Invalid JSON" }),
};
}
} else if (request.method === 'POST' && request.url === '/handshake') {
} else if (request.method === "POST" && request.url === "/handshake") {
this.onHandshake();
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'ok' })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ok" }),
};
} else {
return {
statusCode: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
headers: { "Content-Type": "text/plain" },
body: "Not Found",
};
}
} catch (error) {
console.error('[RemoteControl] Request handler error:', error);
console.info("[RemoteControl] Request handler error:", error);
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Internal Server Error' })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Internal Server Error" }),
};
}
});
@@ -117,20 +118,20 @@ class RemoteControlService {
}
public async startServer(): Promise<string> {
console.log('[RemoteControl] Attempting to start server...');
console.log("[RemoteControl] Attempting to start server...");
try {
const url = await this.httpServer.start();
console.log(`[RemoteControl] Server started successfully at: ${url}`);
return url;
} catch (error) {
console.error('[RemoteControl] Failed to start server:', error);
throw new Error(error instanceof Error ? error.message : 'Failed to start server');
console.info("[RemoteControl] Failed to start server:", error);
throw new Error(error instanceof Error ? error.message : "Failed to start server");
}
}
public stopServer() {
console.log('[RemoteControl] Stopping server...');
console.log("[RemoteControl] Stopping server...");
this.httpServer.stop();
}
@@ -139,4 +140,4 @@ class RemoteControlService {
}
}
export const remoteControlService = new RemoteControlService();
export const remoteControlService = new RemoteControlService();

View File

@@ -1,27 +1,27 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { PlayRecord as ApiPlayRecord } from "./api"; // Use a consistent type
import { api, PlayRecord as ApiPlayRecord, Favorite as ApiFavorite } from "./api";
import { storageConfig } from "./storageConfig";
// --- Storage Keys ---
const STORAGE_KEYS = {
SETTINGS: "mytv_settings",
PLAYER_SETTINGS: "mytv_player_settings",
FAVORITES: "mytv_favorites",
PLAY_RECORDS: "mytv_play_records",
SEARCH_HISTORY: "mytv_search_history",
SETTINGS: "mytv_settings",
} as const;
// --- Type Definitions (aligned with api.ts) ---
export interface PlayRecord extends ApiPlayRecord {
// Re-exporting for consistency, though they are now primarily API types
export type PlayRecord = ApiPlayRecord & {
introEndTime?: number;
outroStartTime?: number;
}
};
export type Favorite = ApiFavorite;
export interface FavoriteItem {
id: string;
source: string;
title: string;
poster: string;
source_name: string;
save_time: number;
export interface PlayerSettings {
introEndTime?: number;
outroStartTime?: number;
}
export interface AppSettings {
@@ -32,59 +32,106 @@ export interface AppSettings {
sources: {
[key: string]: boolean;
};
},
};
m3uUrl: string;
}
// --- Helper ---
const generateKey = (source: string, id: string) => `${source}+${id}`;
// --- FavoriteManager ---
export class FavoriteManager {
static async getAll(): Promise<Record<string, FavoriteItem>> {
// --- PlayerSettingsManager (Uses AsyncStorage) ---
export class PlayerSettingsManager {
static async getAll(): Promise<Record<string, PlayerSettings>> {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES);
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAYER_SETTINGS);
return data ? JSON.parse(data) : {};
} catch (error) {
console.error("Failed to get favorites:", error);
console.info("Failed to get all player settings:", error);
return {};
}
}
static async save(
source: string,
id: string,
item: Omit<FavoriteItem, "id" | "source" | "save_time">
): Promise<void> {
const favorites = await this.getAll();
static async get(source: string, id: string): Promise<PlayerSettings | null> {
const allSettings = await this.getAll();
return allSettings[generateKey(source, id)] || null;
}
static async save(source: string, id: string, settings: PlayerSettings): Promise<void> {
const allSettings = await this.getAll();
const key = generateKey(source, id);
favorites[key] = { ...item, id, source, save_time: Date.now() };
await AsyncStorage.setItem(
STORAGE_KEYS.FAVORITES,
JSON.stringify(favorites)
);
// Only save if there are actual values to save
if (settings.introEndTime !== undefined || settings.outroStartTime !== undefined) {
allSettings[key] = { ...allSettings[key], ...settings };
} else {
// If both are undefined, remove the key
delete allSettings[key];
}
await AsyncStorage.setItem(STORAGE_KEYS.PLAYER_SETTINGS, JSON.stringify(allSettings));
}
static async remove(source: string, id: string): Promise<void> {
const favorites = await this.getAll();
const allSettings = await this.getAll();
delete allSettings[generateKey(source, id)];
await AsyncStorage.setItem(STORAGE_KEYS.PLAYER_SETTINGS, JSON.stringify(allSettings));
}
static async clearAll(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.PLAYER_SETTINGS);
}
}
// --- FavoriteManager (Dynamic: API or LocalStorage) ---
export class FavoriteManager {
private static getStorageType() {
return storageConfig.getStorageType();
}
static async getAll(): Promise<Record<string, Favorite>> {
if (this.getStorageType() === "localstorage") {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES);
return data ? JSON.parse(data) : {};
} catch (error) {
console.info("Failed to get all local favorites:", error);
return {};
}
}
return (await api.getFavorites()) as Record<string, Favorite>;
}
static async save(source: string, id: string, item: Favorite): Promise<void> {
const key = generateKey(source, id);
delete favorites[key];
await AsyncStorage.setItem(
STORAGE_KEYS.FAVORITES,
JSON.stringify(favorites)
);
if (this.getStorageType() === "localstorage") {
const allFavorites = await this.getAll();
allFavorites[key] = { ...item, save_time: Date.now() };
await AsyncStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(allFavorites));
return;
}
await api.addFavorite(key, item);
}
static async remove(source: string, id: string): Promise<void> {
const key = generateKey(source, id);
if (this.getStorageType() === "localstorage") {
const allFavorites = await this.getAll();
delete allFavorites[key];
await AsyncStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(allFavorites));
return;
}
await api.deleteFavorite(key);
}
static async isFavorited(source: string, id: string): Promise<boolean> {
const favorites = await this.getAll();
return generateKey(source, id) in favorites;
const key = generateKey(source, id);
if (this.getStorageType() === "localstorage") {
const allFavorites = await this.getAll();
return !!allFavorites[key];
}
const favorite = await api.getFavorites(key);
return favorite !== null;
}
static async toggle(
source: string,
id: string,
item: Omit<FavoriteItem, "id" | "source" | "save_time">
): Promise<boolean> {
static async toggle(source: string, id: string, item: Favorite): Promise<boolean> {
const isFav = await this.isFavorited(source, id);
if (isFav) {
await this.remove(source, id);
@@ -96,90 +143,134 @@ export class FavoriteManager {
}
static async clearAll(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.FAVORITES);
if (this.getStorageType() === "localstorage") {
await AsyncStorage.removeItem(STORAGE_KEYS.FAVORITES);
return;
}
await api.deleteFavorite();
}
}
// --- PlayRecordManager ---
// --- PlayRecordManager (Dynamic: API or LocalStorage) ---
export class PlayRecordManager {
static async getAll(): Promise<Record<string, PlayRecord>> {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAY_RECORDS);
return data ? JSON.parse(data) : {};
} catch (error) {
console.error("Failed to get play records:", error);
return {};
}
private static getStorageType() {
return storageConfig.getStorageType();
}
static async save(
source: string,
id: string,
record: Omit<PlayRecord, "user_id" | "save_time">
): Promise<void> {
const records = await this.getAll();
static async getAll(): Promise<Record<string, PlayRecord>> {
let apiRecords: Record<string, PlayRecord> = {};
if (this.getStorageType() === "localstorage") {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAY_RECORDS);
apiRecords = data ? JSON.parse(data) : {};
} catch (error) {
console.info("Failed to get all local play records:", error);
return {};
}
} else {
apiRecords = await api.getPlayRecords();
}
const localSettings = await PlayerSettingsManager.getAll();
const mergedRecords: Record<string, PlayRecord> = {};
for (const key in apiRecords) {
mergedRecords[key] = {
...apiRecords[key],
...localSettings[key],
};
}
return mergedRecords;
}
static async save(source: string, id: string, record: Omit<PlayRecord, "save_time">): Promise<void> {
const key = generateKey(source, id);
records[key] = { ...record, user_id: 0, save_time: Date.now() };
await AsyncStorage.setItem(
STORAGE_KEYS.PLAY_RECORDS,
JSON.stringify(records)
);
const { introEndTime, outroStartTime, ...apiRecord } = record;
// Player settings are always saved locally
await PlayerSettingsManager.save(source, id, { introEndTime, outroStartTime });
if (this.getStorageType() === "localstorage") {
const allRecords = await this.getAll();
const fullRecord = { ...apiRecord, save_time: Date.now() };
allRecords[key] = { ...allRecords[key], ...fullRecord };
await AsyncStorage.setItem(STORAGE_KEYS.PLAY_RECORDS, JSON.stringify(allRecords));
} else {
await api.savePlayRecord(key, apiRecord);
}
}
static async get(source: string, id: string): Promise<PlayRecord | null> {
const key = generateKey(source, id);
const records = await this.getAll();
return records[generateKey(source, id)] || null;
return records[key] || null;
}
static async remove(source: string, id: string): Promise<void> {
const records = await this.getAll();
delete records[generateKey(source, id)];
await AsyncStorage.setItem(
STORAGE_KEYS.PLAY_RECORDS,
JSON.stringify(records)
);
const key = generateKey(source, id);
await PlayerSettingsManager.remove(source, id); // Always remove local settings
if (this.getStorageType() === "localstorage") {
const allRecords = await this.getAll();
delete allRecords[key];
await AsyncStorage.setItem(STORAGE_KEYS.PLAY_RECORDS, JSON.stringify(allRecords));
} else {
await api.deletePlayRecord(key);
}
}
static async clearAll(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.PLAY_RECORDS);
await PlayerSettingsManager.clearAll(); // Always clear local settings
if (this.getStorageType() === "localstorage") {
await AsyncStorage.removeItem(STORAGE_KEYS.PLAY_RECORDS);
} else {
await api.deletePlayRecord();
}
}
}
// --- SearchHistoryManager ---
const SEARCH_HISTORY_LIMIT = 20;
// --- SearchHistoryManager (Dynamic: API or LocalStorage) ---
export class SearchHistoryManager {
private static getStorageType() {
return storageConfig.getStorageType();
}
static async get(): Promise<string[]> {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error("Failed to get search history:", error);
return [];
if (this.getStorageType() === "localstorage") {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.info("Failed to get local search history:", error);
return [];
}
}
return api.getSearchHistory();
}
static async add(keyword: string): Promise<void> {
const trimmed = keyword.trim();
if (!trimmed) return;
const history = await this.get();
const newHistory = [trimmed, ...history.filter((k) => k !== trimmed)];
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
newHistory.length = SEARCH_HISTORY_LIMIT;
if (this.getStorageType() === "localstorage") {
let history = await this.get();
history = [trimmed, ...history.filter((k) => k !== trimmed)].slice(0, 20); // Keep latest 20
await AsyncStorage.setItem(STORAGE_KEYS.SEARCH_HISTORY, JSON.stringify(history));
return;
}
await AsyncStorage.setItem(
STORAGE_KEYS.SEARCH_HISTORY,
JSON.stringify(newHistory)
);
await api.addSearchHistory(trimmed);
}
static async clear(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.SEARCH_HISTORY);
if (this.getStorageType() === "localstorage") {
await AsyncStorage.removeItem(STORAGE_KEYS.SEARCH_HISTORY);
return;
}
await api.deleteSearchHistory();
}
}
// --- SettingsManager ---
// --- SettingsManager (Uses AsyncStorage) ---
export class SettingsManager {
static async get(): Promise<AppSettings> {
const defaultSettings: AppSettings = {
@@ -189,15 +280,13 @@ export class SettingsManager {
enabledAll: true,
sources: {},
},
m3uUrl: "https://ghfast.top/https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u",
m3uUrl: "",
};
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.SETTINGS);
return data
? { ...defaultSettings, ...JSON.parse(data) }
: defaultSettings;
return data ? { ...defaultSettings, ...JSON.parse(data) } : defaultSettings;
} catch (error) {
console.error("Failed to get settings:", error);
console.info("Failed to get settings:", error);
return defaultSettings;
}
}
@@ -205,10 +294,7 @@ export class SettingsManager {
static async save(settings: Partial<AppSettings>): Promise<void> {
const currentSettings = await this.get();
const updatedSettings = { ...currentSettings, ...settings };
await AsyncStorage.setItem(
STORAGE_KEYS.SETTINGS,
JSON.stringify(updatedSettings)
);
await AsyncStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(updatedSettings));
}
static async reset(): Promise<void> {

20
services/storageConfig.ts Normal file
View File

@@ -0,0 +1,20 @@
// Define a simple storage configuration service
export interface StorageConfig {
storageType: string | undefined;
getStorageType: () => string | undefined;
setStorageType: (type: string | undefined) => void;
}
// Create a singleton instance
export const storageConfig: StorageConfig = {
// Default to undefined (will fallback to local storage)
storageType: undefined,
getStorageType() {
return this.storageType;
},
setStorageType(type: string | undefined) {
this.storageType = type;
},
};

View File

@@ -59,7 +59,7 @@ class TCPHttpServer {
return { method, url, headers, body };
} catch (error) {
console.error('[TCPHttpServer] Error parsing HTTP request:', error);
console.info('[TCPHttpServer] Error parsing HTTP request:', error);
return null;
}
}
@@ -140,7 +140,7 @@ class TCPHttpServer {
socket.write(errorResponse);
}
} catch (error) {
console.error('[TCPHttpServer] Error handling request:', error);
console.info('[TCPHttpServer] Error handling request:', error);
const errorResponse = this.formatHttpResponse({
statusCode: 500,
headers: { 'Content-Type': 'text/plain' },
@@ -155,7 +155,7 @@ class TCPHttpServer {
});
socket.on('error', (error: Error) => {
console.error('[TCPHttpServer] Socket error:', error);
console.info('[TCPHttpServer] Socket error:', error);
});
socket.on('close', () => {
@@ -170,13 +170,13 @@ class TCPHttpServer {
});
this.server.on('error', (error: Error) => {
console.error('[TCPHttpServer] Server error:', error);
console.info('[TCPHttpServer] Server error:', error);
this.isRunning = false;
reject(error);
});
} catch (error) {
console.error('[TCPHttpServer] Failed to start server:', error);
console.info('[TCPHttpServer] Failed to start server:', error);
reject(error);
}
});

61
stores/authStore.ts Normal file
View File

@@ -0,0 +1,61 @@
import { create } from "zustand";
import Cookies from "@react-native-cookies/cookies";
import { api } from "@/services/api";
import { useSettingsStore } from "./settingsStore";
interface AuthState {
isLoggedIn: boolean;
isLoginModalVisible: boolean;
showLoginModal: () => void;
hideLoginModal: () => void;
checkLoginStatus: (apiBaseUrl?: string) => Promise<void>;
logout: () => Promise<void>;
}
const useAuthStore = create<AuthState>((set) => ({
isLoggedIn: false,
isLoginModalVisible: false,
showLoginModal: () => set({ isLoginModalVisible: true }),
hideLoginModal: () => set({ isLoginModalVisible: false }),
checkLoginStatus: async (apiBaseUrl?: string) => {
if (!apiBaseUrl) {
set({ isLoggedIn: false, isLoginModalVisible: false });
return;
}
try {
const serverConfig = useSettingsStore.getState().serverConfig;
const cookies = await Cookies.get(api.baseURL);
if (serverConfig && serverConfig.StorageType === "localstorage" && !cookies.auth) {
const loginResult = await api.login().catch(() => {
set({ isLoggedIn: false, isLoginModalVisible: true });
});
if (loginResult && loginResult.ok) {
set({ isLoggedIn: true });
}
} else {
const isLoggedIn = cookies && !!cookies.auth;
set({ isLoggedIn });
if (!isLoggedIn) {
set({ isLoginModalVisible: true });
}
}
} catch (error) {
console.info("Failed to check login status:", error);
if (error instanceof Error && error.message === "UNAUTHORIZED") {
set({ isLoggedIn: false, isLoginModalVisible: true });
} else {
set({ isLoggedIn: false });
}
}
},
logout: async () => {
try {
await Cookies.clearAll();
set({ isLoggedIn: false, isLoginModalVisible: true });
} catch (error) {
console.info("Failed to logout:", error);
}
},
}));
export default useAuthStore;

186
stores/detailStore.ts Normal file
View File

@@ -0,0 +1,186 @@
import { create } from "zustand";
import { SearchResult, api } from "@/services/api";
import { getResolutionFromM3U8 } from "@/services/m3u8";
import { useSettingsStore } from "@/stores/settingsStore";
import { FavoriteManager } from "@/services/storage";
export type SearchResultWithResolution = SearchResult & { resolution?: string | null };
interface DetailState {
q: string | null;
searchResults: SearchResultWithResolution[];
sources: { source: string; source_name: string; resolution: string | null | undefined }[];
detail: SearchResultWithResolution | null;
loading: boolean;
error: string | null;
allSourcesLoaded: boolean;
controller: AbortController | null;
isFavorited: boolean;
init: (q: string, preferredSource?: string, id?: string) => Promise<void>;
setDetail: (detail: SearchResultWithResolution) => void;
abort: () => void;
toggleFavorite: () => Promise<void>;
}
const useDetailStore = create<DetailState>((set, get) => ({
q: null,
searchResults: [],
sources: [],
detail: null,
loading: true,
error: null,
allSourcesLoaded: false,
controller: null,
isFavorited: false,
init: async (q, preferredSource, id) => {
const { controller: oldController } = get();
if (oldController) {
oldController.abort();
}
const newController = new AbortController();
const signal = newController.signal;
set({
q,
loading: true,
searchResults: [],
detail: null,
error: null,
allSourcesLoaded: false,
controller: newController,
});
const { videoSource } = useSettingsStore.getState();
const processAndSetResults = async (results: SearchResult[], merge = false) => {
const resultsWithResolution = await Promise.all(
results.map(async (searchResult) => {
let resolution;
try {
if (searchResult.episodes && searchResult.episodes.length > 0) {
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
}
} catch (e) {
if ((e as Error).name !== "AbortError") {
console.info(`Failed to get resolution for ${searchResult.source_name}`, e);
}
}
return { ...searchResult, resolution };
})
);
if (signal.aborted) return;
set((state) => {
const existingSources = new Set(state.searchResults.map((r) => r.source));
const newResults = resultsWithResolution.filter((r) => !existingSources.has(r.source));
const finalResults = merge ? [...state.searchResults, ...newResults] : resultsWithResolution;
return {
searchResults: finalResults,
sources: finalResults.map((r) => ({
source: r.source,
source_name: r.source_name,
resolution: r.resolution,
})),
detail: state.detail ?? finalResults[0] ?? null,
};
});
};
try {
// Optimization for favorite navigation
if (preferredSource && id) {
const { results: preferredResult } = await api.searchVideo(q, preferredSource, signal);
if (signal.aborted) return;
if (preferredResult.length > 0) {
await processAndSetResults(preferredResult, false);
set({ loading: false });
}
// Then load all others in background
const { results: allResults } = await api.searchVideos(q);
if (signal.aborted) return;
await processAndSetResults(allResults, true);
} else {
// Standard navigation: fetch resources, then fetch details one by one
const allResources = await api.getResources(signal);
const enabledResources = videoSource.enabledAll
? allResources
: allResources.filter((r) => videoSource.sources[r.key]);
let firstResultFound = false;
const searchPromises = enabledResources.map(async (resource) => {
try {
const { results } = await api.searchVideo(q, resource.key, signal);
if (results.length > 0) {
await processAndSetResults(results, true);
if (!firstResultFound) {
set({ loading: false }); // Stop loading indicator on first result
firstResultFound = true;
}
}
} catch (error) {
console.info(`Failed to fetch from ${resource.name}:`, error);
}
});
await Promise.all(searchPromises);
}
if (get().searchResults.length === 0) {
set({ error: "未找到任何播放源" });
}
// 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 : "获取数据失败" });
}
} finally {
if (!signal.aborted) {
set({ loading: false, allSourcesLoaded: true });
}
}
},
setDetail: async (detail) => {
set({ detail });
// const { source, id } = detail;
// const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
// set({ isFavorited });
},
abort: () => {
get().controller?.abort();
},
toggleFavorite: async () => {
const { detail } = get();
if (!detail) return;
const { source, id, title, poster, source_name, episodes, year } = detail;
const favoriteItem = {
cover: poster,
title,
poster,
source_name,
total_episodes: episodes.length,
search_title: get().q!,
year: year || "",
};
const newIsFavorited = await FavoriteManager.toggle(source, id.toString(), favoriteItem);
set({ isFavorited: newIsFavorited });
},
}));
export const sourcesSelector = (state: DetailState) => state.sources;
export default useDetailStore;
export const episodesSelectorBySource = (source: string) => (state: DetailState) =>
state.searchResults.find((r) => r.source === source)?.episodes || [];

32
stores/favoritesStore.ts Normal file
View File

@@ -0,0 +1,32 @@
import { create } from "zustand";
import { Favorite, FavoriteManager } from "@/services/storage";
interface FavoritesState {
favorites: (Favorite & { key: string })[];
loading: boolean;
error: string | null;
fetchFavorites: () => Promise<void>;
}
const useFavoritesStore = create<FavoritesState>((set) => ({
favorites: [],
loading: false,
error: null,
fetchFavorites: async () => {
set({ loading: true, error: null });
try {
const favoritesData = await FavoriteManager.getAll();
const favoritesArray = Object.entries(favoritesData).map(([key, value]) => ({
...value,
key,
}));
// favoritesArray.sort((a, b) => (b.save_time || 0) - (a.save_time || 0));
set({ favorites: favoritesArray, loading: false });
} catch (e) {
const error = e instanceof Error ? e.message : "获取收藏列表失败";
set({ error, loading: false });
}
},
}));
export default useFavoritesStore;

View File

@@ -1,6 +1,8 @@
import { create } from 'zustand';
import { api, SearchResult, PlayRecord } from '@/services/api';
import { PlayRecordManager } from '@/services/storage';
import { create } from "zustand";
import { api, SearchResult, PlayRecord } from "@/services/api";
import { PlayRecordManager } from "@/services/storage";
import useAuthStore from "./authStore";
import { useSettingsStore } from "./settingsStore";
export type RowItem = (SearchResult | PlayRecord) & {
id: string;
@@ -19,18 +21,38 @@ export type RowItem = (SearchResult | PlayRecord) & {
export interface Category {
title: string;
type?: 'movie' | 'tv' | 'record';
type?: "movie" | "tv" | "record";
tag?: string;
tags?: string[];
}
const initialCategories: Category[] = [
{ title: '最近播放', type: 'record' },
{ title: '热门剧集', type: 'tv', tag: '热门' },
{ title: '电视剧', type: 'tv', tags: [ '国产剧', '美剧', '英剧', '韩剧', '日剧', '港剧', '日本动画', '动画'] },
{ title: '电影', type: 'movie', tags: ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '爱情', '科幻', '悬疑', '恐怖'] },
{ title: '综艺', type: 'tv', tag: '综艺' },
{ title: '豆瓣 Top250', type: 'movie', tag: 'top250' },
{ title: "最近播放", type: "record" },
{ title: "热门剧集", type: "tv", tag: "热门" },
{ title: "电视剧", type: "tv", tags: ["国产剧", "美剧", "英剧", "韩剧", "日剧", "港剧", "日本动画", "动画"] },
{
title: "电影",
type: "movie",
tags: [
"热门",
"最新",
"经典",
"豆瓣高分",
"冷门佳片",
"华语",
"欧美",
"韩国",
"日本",
"动作",
"喜剧",
"爱情",
"科幻",
"悬疑",
"恐怖",
],
},
{ title: "综艺", type: "tv", tag: "综艺" },
{ title: "豆瓣 Top250", type: "movie", tag: "top250" },
];
interface HomeState {
@@ -59,6 +81,8 @@ const useHomeStore = create<HomeState>((set, get) => ({
error: null,
fetchInitialData: async () => {
const { apiBaseUrl } = useSettingsStore.getState();
await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData();
},
@@ -72,28 +96,44 @@ const useHomeStore = create<HomeState>((set, get) => ({
}
try {
if (selectedCategory.type === 'record') {
if (selectedCategory.type === "record") {
const { isLoggedIn } = useAuthStore.getState();
if (!isLoggedIn) {
set({ contentData: [], hasMore: false });
return;
}
const records = await PlayRecordManager.getAll();
const rowItems = Object.entries(records)
.map(([key, record]) => {
const [source, id] = key.split('+');
return { ...record, id, source, progress: record.play_time / record.total_time, poster: record.cover, sourceName: record.source_name, episodeIndex: record.index, totalEpisodes: record.total_episodes, lastPlayed: record.save_time, play_time: record.play_time };
const [source, id] = key.split("+");
return {
...record,
id,
source,
progress: record.play_time / record.total_time,
poster: record.cover,
sourceName: record.source_name,
episodeIndex: record.index,
totalEpisodes: record.total_episodes,
lastPlayed: record.save_time,
play_time: record.play_time,
};
})
.filter(record => record.progress !== undefined && record.progress > 0 && record.progress < 1)
// .filter((record) => record.progress !== undefined && record.progress > 0 && record.progress < 1)
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
set({ contentData: rowItems, hasMore: false });
} else if (selectedCategory.type && selectedCategory.tag) {
const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart);
if (result.list.length === 0) {
set({ hasMore: false });
} else {
const newItems = result.list.map(item => ({
const newItems = result.list.map((item) => ({
...item,
id: item.title,
source: 'douban',
source: "douban",
})) as RowItem[];
set(state => ({
set((state) => ({
contentData: pageStart === 0 ? newItems : [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length,
hasMore: true,
@@ -106,10 +146,10 @@ const useHomeStore = create<HomeState>((set, get) => ({
set({ hasMore: false });
}
} catch (err: any) {
if (err.message === 'API_URL_NOT_SET') {
set({ error: '请点击右上角设置按钮,配置您的 API 地址' });
if (err.message === "API_URL_NOT_SET") {
set({ error: "请点击右上角设置按钮,配置您的 API 地址" });
} else {
set({ error: '加载失败,请重试' });
set({ error: "加载失败,请重试" });
}
} finally {
set({ loading: false, loadingMore: false });
@@ -126,26 +166,41 @@ const useHomeStore = create<HomeState>((set, get) => ({
},
refreshPlayRecords: async () => {
const { apiBaseUrl } = useSettingsStore.getState();
await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
const { isLoggedIn } = useAuthStore.getState();
if (!isLoggedIn) {
set((state) => {
const recordCategoryExists = state.categories.some((c) => c.type === "record");
if (recordCategoryExists) {
const newCategories = state.categories.filter((c) => c.type !== "record");
if (state.selectedCategory.type === "record") {
get().selectCategory(newCategories[0] || null);
}
return { categories: newCategories };
}
return {};
});
return;
}
const records = await PlayRecordManager.getAll();
const hasRecords = Object.keys(records).length > 0;
set(state => {
const recordCategoryExists = state.categories.some(c => c.type === 'record');
set((state) => {
const recordCategoryExists = state.categories.some((c) => c.type === "record");
if (hasRecords && !recordCategoryExists) {
return { categories: [initialCategories[0], ...state.categories] };
}
if (!hasRecords && recordCategoryExists) {
const newCategories = state.categories.filter(c => c.type !== 'record');
if (state.selectedCategory.type === 'record') {
get().selectCategory(newCategories[0] || null);
const newCategories = state.categories.filter((c) => c.type !== "record");
if (state.selectedCategory.type === "record") {
get().selectCategory(newCategories[0] || null);
}
return { categories: newCategories };
}
return {};
});
if (get().selectedCategory.type === 'record') {
get().fetchInitialData();
}
get().fetchInitialData();
},
}));
export default useHomeStore;
export default useHomeStore;

View File

@@ -2,27 +2,18 @@ import { create } from "zustand";
import Toast from "react-native-toast-message";
import { AVPlaybackStatus, Video } from "expo-av";
import { RefObject } from "react";
import { api, VideoDetail as ApiVideoDetail, SearchResult } from "@/services/api";
import { PlayRecord, PlayRecordManager } from "@/services/storage";
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
interface Episode {
url: string;
title: string;
}
interface VideoDetail {
videoInfo: ApiVideoDetail["videoInfo"];
episodes: Episode[];
sources: SearchResult[];
}
interface PlayerState {
videoRef: RefObject<Video> | null;
detail: VideoDetail | null;
episodes: Episode[];
sources: SearchResult[];
currentSourceIndex: number;
currentEpisodeIndex: number;
episodes: Episode[];
status: AVPlaybackStatus | null;
isLoading: boolean;
showControls: boolean;
@@ -36,8 +27,7 @@ interface PlayerState {
introEndTime?: number;
outroStartTime?: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
switchSource: (newSourceIndex: 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;
@@ -57,11 +47,8 @@ interface PlayerState {
const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null,
detail: null,
episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0,
currentEpisodeIndex: -1,
status: null,
isLoading: true,
showControls: false,
@@ -78,72 +65,45 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
setVideoRef: (ref) => set({ videoRef: ref }),
loadVideo: async (source, id, episodeIndex, position) => {
loadVideo: async ({ source, id, episodeIndex, position, title }) => {
let detail = useDetailStore.getState().detail;
let episodes = episodesSelectorBySource(source)(useDetailStore.getState());
set({
isLoading: true,
detail: null,
episodes: [],
sources: [],
currentEpisodeIndex: 0,
initialPosition: position || 0,
});
if (!detail || !episodes || episodes.length === 0 || detail.title !== title) {
await useDetailStore.getState().init(title, source, id);
detail = useDetailStore.getState().detail;
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
if (!detail) {
console.info("Detail not found after initialization");
return;
}
};
try {
const videoDetail = await api.getVideoDetail(source, id);
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
const searchResults = await api.searchVideos(videoDetail.videoInfo.title);
const sources = searchResults.results.filter((r) => r.title === videoDetail.videoInfo.title);
const currentSourceIndex = sources.findIndex((s) => s.source === source && s.id.toString() === id);
const playRecord = await PlayRecordManager.get(source, id);
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
set({
detail: { videoInfo: videoDetail.videoInfo, episodes, sources },
episodes,
sources,
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
currentEpisodeIndex: episodeIndex,
isLoading: false,
currentEpisodeIndex: episodeIndex,
initialPosition: position || initialPositionFromRecord,
episodes: episodes.map((ep, index) => ({
url: ep,
title: `${index + 1}`,
})),
introEndTime: playRecord?.introEndTime,
outroStartTime: playRecord?.outroStartTime,
});
} catch (error) {
console.error("Failed to load video details", error);
console.info("Failed to load play record", error);
set({ isLoading: false });
}
},
switchSource: async (newSourceIndex: number) => {
const { sources, currentEpisodeIndex, status, detail } = get();
if (!detail || newSourceIndex < 0 || newSourceIndex >= sources.length) return;
const newSource = sources[newSourceIndex];
const position = status?.isLoaded ? status.positionMillis : 0;
set({ isLoading: true, showSourceModal: false });
try {
const videoDetail = await api.getVideoDetail(newSource.source, newSource.id.toString());
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
set({
detail: {
...detail,
videoInfo: videoDetail.videoInfo,
episodes,
},
episodes,
currentSourceIndex: newSourceIndex,
currentEpisodeIndex: currentEpisodeIndex < episodes.length ? currentEpisodeIndex : 0,
initialPosition: position,
isLoading: false,
});
} catch (error) {
console.error("Failed to switch source", error);
set({ isLoading: false });
}
},
playEpisode: (index) => {
playEpisode: async (index) => {
const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) {
set({
@@ -153,27 +113,42 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
progressPosition: 0,
seekPosition: 0,
});
videoRef?.current?.replayAsync();
}
},
togglePlayPause: () => {
const { status, videoRef } = get();
if (status?.isLoaded) {
if (status.isPlaying) {
videoRef?.current?.pauseAsync();
} else {
videoRef?.current?.playAsync();
try {
await videoRef?.current?.replayAsync();
} catch (error) {
console.error("Failed to replay video:", error);
Toast.show({ type: "error", text1: "播放失败" });
}
}
},
seek: (duration) => {
togglePlayPause: async () => {
const { status, videoRef } = get();
if (status?.isLoaded) {
try {
if (status.isPlaying) {
await videoRef?.current?.pauseAsync();
} else {
await videoRef?.current?.playAsync();
}
} catch (error) {
console.error("Failed to toggle play/pause:", error);
Toast.show({ type: "error", text1: "操作失败" });
}
}
},
seek: async (duration) => {
const { status, videoRef } = get();
if (!status?.isLoaded || !status.durationMillis) return;
const newPosition = Math.max(0, Math.min(status.positionMillis + duration, status.durationMillis));
videoRef?.current?.setPositionAsync(newPosition);
try {
await videoRef?.current?.setPositionAsync(newPosition);
} catch (error) {
console.error("Failed to seek video:", error);
Toast.show({ type: "error", text1: "快进/快退失败" });
}
set({
isSeeking: true,
@@ -188,7 +163,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
},
setIntroEndTime: () => {
const { status, detail, introEndTime: existingIntroEndTime } = get();
const { status, introEndTime: existingIntroEndTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return;
if (existingIntroEndTime) {
@@ -213,7 +189,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
},
setOutroStartTime: () => {
const { status, detail, outroStartTime: existingOutroStartTime } = get();
const { status, outroStartTime: existingOutroStartTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return;
if (existingOutroStartTime) {
@@ -226,7 +203,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
});
} else {
// Set the time
const newOutroStartTime = status.positionMillis;
if (!status.durationMillis) return;
const newOutroStartTime = status.durationMillis - status.positionMillis;
set({ outroStartTime: newOutroStartTime });
get()._savePlayRecord({ outroStartTime: newOutroStartTime });
Toast.show({
@@ -238,21 +216,22 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
},
_savePlayRecord: (updates = {}) => {
const { detail, currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
const { detail } = useDetailStore.getState();
const { currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
if (detail && status?.isLoaded) {
const { videoInfo } = detail;
const existingRecord = {
introEndTime,
outroStartTime,
};
PlayRecordManager.save(videoInfo.source, videoInfo.id, {
title: videoInfo.title,
cover: videoInfo.cover || "",
index: currentEpisodeIndex,
PlayRecordManager.save(detail.source, detail.id.toString(), {
title: detail.title,
cover: detail.poster || "",
index: currentEpisodeIndex + 1,
total_episodes: episodes.length,
play_time: status.positionMillis,
total_time: status.durationMillis || 0,
source_name: videoInfo.source_name,
play_time: Math.floor(status.positionMillis / 1000),
total_time: status.durationMillis ? Math.floor(status.durationMillis / 1000) : 0,
source_name: detail.source_name,
year: detail.year || "",
...existingRecord,
...updates,
});
@@ -262,15 +241,20 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
handlePlaybackStatusUpdate: (newStatus) => {
if (!newStatus.isLoaded) {
if (newStatus.error) {
console.error(`Playback Error: ${newStatus.error}`);
console.info(`Playback Error: ${newStatus.error}`);
}
set({ status: newStatus });
return;
}
const { detail, currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
const { currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
const detail = useDetailStore.getState().detail;
if (outroStartTime && newStatus.positionMillis >= outroStartTime) {
if (
outroStartTime &&
newStatus.durationMillis &&
newStatus.positionMillis >= newStatus.durationMillis - outroStartTime
) {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
return; // Stop further processing for this update
@@ -306,10 +290,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
reset: () => {
set({
detail: null,
episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0,
status: null,
isLoading: true,
@@ -325,3 +306,9 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}));
export default usePlayerStore;
export const selectCurrentEpisode = (state: PlayerState) => {
if (state.episodes.length > state.currentEpisodeIndex) {
return state.episodes[state.currentEpisodeIndex];
}
};

View File

@@ -41,7 +41,7 @@ export const useRemoteControlStore = create<RemoteControlState>((set, get) => ({
set({ isServerRunning: true, serverUrl: url, error: null });
} catch {
const errorMessage = '启动失败,请强制退应用后重试。';
console.error('[RemoteControlStore] Failed to start server:', errorMessage);
console.info('[RemoteControlStore] Failed to start server:', errorMessage);
set({ error: errorMessage });
}
},

View File

@@ -1,8 +1,7 @@
import { create } from 'zustand';
import { SettingsManager } from '@/services/storage';
import { api } from '@/services/api';
import useHomeStore from './homeStore';
import { create } from "zustand";
import { SettingsManager } from "@/services/storage";
import { api, ServerConfig } from "@/services/api";
import { storageConfig } from "@/services/storageConfig";
interface SettingsState {
apiBaseUrl: string;
@@ -15,29 +14,32 @@ interface SettingsState {
};
};
isModalVisible: boolean;
serverConfig: ServerConfig | null;
loadSettings: () => Promise<void>;
fetchServerConfig: () => Promise<void>;
setApiBaseUrl: (url: string) => void;
setM3uUrl: (url: string) => void;
setRemoteInputEnabled: (enabled: boolean) => void;
saveSettings: () => Promise<void>;
setVideoSource: (config: { enabledAll: boolean; sources: {[key: string]: boolean} }) => void;
setVideoSource: (config: { enabledAll: boolean; sources: { [key: string]: boolean } }) => void;
showModal: () => void;
hideModal: () => void;
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
apiBaseUrl: '',
m3uUrl: 'https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u',
apiBaseUrl: "",
m3uUrl: "",
liveStreamSources: [],
remoteInputEnabled: false,
isModalVisible: false,
serverConfig: null,
videoSource: {
enabledAll: true,
sources: {},
},
loadSettings: async () => {
const settings = await SettingsManager.get();
set({
set({
apiBaseUrl: settings.apiBaseUrl,
m3uUrl: settings.m3uUrl,
remoteInputEnabled: settings.remoteInputEnabled || false,
@@ -47,6 +49,18 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
},
});
api.setBaseUrl(settings.apiBaseUrl);
await get().fetchServerConfig();
},
fetchServerConfig: async () => {
try {
const config = await api.getServerConfig();
if (config) {
storageConfig.setStorageType(config.StorageType);
}
set({ serverConfig: config });
} catch (error) {
console.info("Failed to fetch server config:", error);
}
},
setApiBaseUrl: (url) => set({ apiBaseUrl: url }),
setM3uUrl: (url) => set({ m3uUrl: url }),
@@ -54,16 +68,37 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setVideoSource: (config) => set({ videoSource: config }),
saveSettings: async () => {
const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get();
await SettingsManager.save({
apiBaseUrl,
let processedApiBaseUrl = apiBaseUrl.trim();
if (processedApiBaseUrl.endsWith("/")) {
processedApiBaseUrl = processedApiBaseUrl.slice(0, -1);
}
if (!/^https?:\/\//i.test(processedApiBaseUrl)) {
const hostPart = processedApiBaseUrl.split("/")[0];
// Simple check for IP address format.
const isIpAddress = /^((\d{1,3}\.){3}\d{1,3})(:\d+)?$/.test(hostPart);
// Check if the domain includes a port.
const hasPort = /:\d+/.test(hostPart);
if (isIpAddress || hasPort) {
processedApiBaseUrl = "http://" + processedApiBaseUrl;
} else {
processedApiBaseUrl = "https://" + processedApiBaseUrl;
}
}
await SettingsManager.save({
apiBaseUrl: processedApiBaseUrl,
m3uUrl,
remoteInputEnabled,
videoSource,
});
api.setBaseUrl(apiBaseUrl);
set({ isModalVisible: false });
useHomeStore.getState().fetchInitialData();
api.setBaseUrl(processedApiBaseUrl);
// Also update the URL in the state so the input field shows the processed URL
set({ isModalVisible: false, apiBaseUrl: processedApiBaseUrl });
await get().fetchServerConfig();
},
showModal: () => set({ isModalVisible: true }),
hideModal: () => set({ isModalVisible: false }),
}));
}));

24
stores/sourceStore.ts Normal file
View File

@@ -0,0 +1,24 @@
import { create } from "zustand";
import { useSettingsStore } from "@/stores/settingsStore";
import useDetailStore, { sourcesSelector } from "./detailStore";
interface SourceState {
toggleResourceEnabled: (resourceKey: string) => void;
}
const useSourceStore = create<SourceState>((set, get) => ({
toggleResourceEnabled: (resourceKey: string) => {
const { videoSource, setVideoSource } = useSettingsStore.getState();
const isEnabled = videoSource.sources[resourceKey];
const newEnabledSources = { ...videoSource.sources, [resourceKey]: !isEnabled };
setVideoSource({
enabledAll: Object.values(newEnabledSources).every((enabled) => enabled),
sources: newEnabledSources,
});
},
}));
export const useSources = () => useDetailStore(sourcesSelector);
export default useSourceStore;

File diff suppressed because it is too large Load Diff

View File

@@ -1762,6 +1762,13 @@
resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.4.1.tgz#a3c247aceab35f75dd0aa4bfa85d2be5a4508688"
integrity sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==
"@react-native-cookies/cookies@^6.2.1":
version "6.2.1"
resolved "https://registry.yarnpkg.com/@react-native-cookies/cookies/-/cookies-6.2.1.tgz#54d50b9496400bbdc19e43c155f70f8f918999e3"
integrity sha512-D17wCA0DXJkGJIxkL74Qs9sZ3sA+c+kCoGmXVknW7bVw/W+Vv1m/7mWTNi9DLBZSRddhzYw8SU0aJapIaM/g5w==
dependencies:
invariant "^2.2.4"
"@react-native-tvos/config-tv@^0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@react-native-tvos/config-tv/-/config-tv-0.0.10.tgz#38fe1571e24c6790b43137d130832c68b366c295"