23 Commits

Author SHA1 Message Date
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
48 changed files with 2141 additions and 10173 deletions

3
.gitignore vendored
View File

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

View File

@@ -18,10 +18,6 @@
- [Expo Router](https://docs.expo.dev/router/introduction/) - [Expo Router](https://docs.expo.dev/router/introduction/)
- [Expo AV](https://docs.expo.dev/versions/latest/sdk/av/) - [Expo AV](https://docs.expo.dev/versions/latest/sdk/av/)
- TypeScript - TypeScript
- **后端**:
- [Node.js](https://nodejs.org/)
- [Express](https://expressjs.com/)
- [TypeScript](https://www.typescriptlang.org/)
## 📂 项目结构 ## 📂 项目结构
@@ -31,7 +27,6 @@
. .
├── app/ # Expo Router 路由和页面 ├── app/ # Expo Router 路由和页面
├── assets/ # 静态资源 (字体, 图片, TV 图标) ├── assets/ # 静态资源 (字体, 图片, TV 图标)
├── backend/ # 后端 Express 应用
├── components/ # React 组件 ├── components/ # React 组件
├── constants/ # 应用常量 (颜色, 样式) ├── constants/ # 应用常量 (颜色, 样式)
├── hooks/ # 自定义 Hooks ├── hooks/ # 自定义 Hooks
@@ -52,24 +47,7 @@
- [Xcode](https://developer.apple.com/xcode/) (用于 Apple TV 开发) - [Xcode](https://developer.apple.com/xcode/) (用于 Apple TV 开发)
- [Android Studio](https://developer.android.com/studio) (用于 Android TV 开发) - [Android Studio](https://developer.android.com/studio) (用于 Android TV 开发)
### 1. 后端服务 ### 项目启动
首先,启动后端服务:
```sh
# 进入后端目录
cd backend
# 安装依赖
yarn
# 启动开发服务器
yarn dev
```
后端服务将运行在 `http://localhost:3001`
### 2. 前端应用
接下来,在项目根目录运行前端应用: 接下来,在项目根目录运行前端应用:
@@ -93,22 +71,14 @@ yarn android-tv
## 部署 ## 部署
### 后端部署 推荐使用 [MoonTV](https://github.com/senshinya/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`
如果不想依赖 MoonTV可以使用 1.1.x 版本。
## 其他 ## 其他
- 最低版本是android 6.0,可用,但是不推荐
- 如果使用https的后端接口无法访问在确认服务没有问题的情况下请检查https的TLS协议Android 10 之后版本才支持 TLS1.3 - 最低版本是 android 6.0,可用,但是不推荐
- 如果使用 https 的后端接口无法访问,在确认服务没有问题的情况下,请检查 https 的 TLS 协议Android 10 之后版本才支持 TLS1.3
## 📜 主要脚本 ## 📜 主要脚本
@@ -136,9 +106,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 的视频聚合应用 - [MoonTV](https://github.com/senshinya/MoonTV) - 一个基于 Next.js 的视频聚合应用
- [LibreTV](https://github.com/LibreSpark/LibreTV) - 一个开源的视频流媒体应用 - [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 { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore"; 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. // Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
@@ -18,13 +19,20 @@ export default function RootLayout() {
const [loaded, error] = useFonts({ const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
}); });
const { loadSettings, remoteInputEnabled } = useSettingsStore(); const { loadSettings, remoteInputEnabled, apiBaseUrl } = useSettingsStore();
const { startServer, stopServer } = useRemoteControlStore(); const { startServer, stopServer } = useRemoteControlStore();
const { checkLoginStatus } = useAuthStore();
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
}, [loadSettings]); }, [loadSettings]);
useEffect(() => {
if (apiBaseUrl) {
checkLoginStatus(apiBaseUrl);
}
}, [apiBaseUrl, checkLoginStatus]);
useEffect(() => { useEffect(() => {
if (loaded || error) { if (loaded || error) {
SplashScreen.hideAsync(); SplashScreen.hideAsync();
@@ -55,9 +63,11 @@ export default function RootLayout() {
<Stack.Screen name="search" options={{ headerShown: false }} /> <Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="live" options={{ headerShown: false }} /> <Stack.Screen name="live" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} /> <Stack.Screen name="settings" options={{ headerShown: false }} />
{/* <Stack.Screen name="favorites" options={{ headerShown: false }} /> */}
<Stack.Screen name="+not-found" /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
<Toast /> <Toast />
<LoginModal />
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@@ -1,134 +1,49 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect } from "react";
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native"; import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator, Pressable } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { api, SearchResult } from "@/services/api";
import { getResolutionFromM3U8 } from "@/services/m3u8";
import { StyledButton } from "@/components/StyledButton"; 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() { export default function DetailScreen() {
const { source, q } = useLocalSearchParams(); const { q, source, id } = useLocalSearchParams<{ q: string; source?: string; id?: string }>();
const router = useRouter(); const router = useRouter();
const [searchResults, setSearchResults] = useState<(SearchResult & { resolution?: string | null })[]>([]);
const [detail, setDetail] = useState<(SearchResult & { resolution?: string | null }) | null>(null); const {
const [loading, setLoading] = useState(true); detail,
const [error, setError] = useState<string | null>(null); searchResults,
const [allSourcesLoaded, setAllSourcesLoaded] = useState(false); loading,
const controllerRef = useRef<AbortController | null>(null); error,
const { videoSource } = useSettingsStore(); allSourcesLoaded,
init,
setDetail,
abort,
isFavorited,
toggleFavorite,
} = useDetailStore();
useEffect(() => { useEffect(() => {
if (controllerRef.current) { if (q) {
controllerRef.current.abort(); 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 () => { 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; if (!detail) return;
controllerRef.current?.abort(); // Cancel any ongoing fetches abort(); // Cancel any ongoing fetches
router.push({ router.push({
pathname: "/play", pathname: "/play",
params: { params: {
// Pass necessary identifiers, the rest will be in the store
q: detail.title,
source: detail.source, source: detail.source,
id: detail.id.toString(), id: detail.id.toString(),
episodeUrl: episodeName, // The "episode" is actually the URL
episodeIndex: episodeIndex.toString(), 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.year}</ThemedText>
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText> <ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
</View> </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}> <ScrollView style={styles.descriptionScrollView}>
<ThemedText style={styles.description}>{detail.desc}</ThemedText> <ThemedText style={styles.description}>{detail.desc}</ThemedText>
</ScrollView> </ScrollView>
@@ -217,7 +136,7 @@ export default function DetailScreen() {
<StyledButton <StyledButton
key={index} key={index}
style={styles.episodeButton} style={styles.episodeButton}
onPress={() => handlePlay(episode, index)} onPress={() => handlePlay(index)}
text={`${index + 1}`} text={`${index + 1}`}
textStyle={styles.episodeButtonText} textStyle={styles.episodeButtonText}
/> />
@@ -274,6 +193,19 @@ const styles = StyleSheet.create({
color: "#ccc", color: "#ccc",
lineHeight: 22, 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: { bottomContainer: {
paddingHorizontal: 20, 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 { api } from "@/services/api";
import VideoCard from "@/components/VideoCard.tv"; import VideoCard from "@/components/VideoCard.tv";
import { useFocusEffect, useRouter } from "expo-router"; 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 { StyledButton } from "@/components/StyledButton";
import useHomeStore, { RowItem, Category } from "@/stores/homeStore"; import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
import useAuthStore from "@/stores/authStore";
const NUM_COLUMNS = 5; const NUM_COLUMNS = 5;
const { width } = Dimensions.get("window"); const { width } = Dimensions.get("window");
@@ -31,6 +32,7 @@ export default function HomeScreen() {
selectCategory, selectCategory,
refreshPlayRecords, refreshPlayRecords,
} = useHomeStore(); } = useHomeStore();
const { isLoggedIn, logout } = useAuthStore();
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -122,6 +124,9 @@ export default function HomeScreen() {
</Pressable> </Pressable>
</View> </View>
<View style={styles.rightHeaderButtons}> <View style={styles.rightHeaderButtons}>
{/* <StyledButton style={styles.searchButton} onPress={() => router.push("/favorites")} variant="ghost">
<Heart color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton> */}
<StyledButton <StyledButton
style={styles.searchButton} style={styles.searchButton}
onPress={() => router.push({ pathname: "/search" })} onPress={() => router.push({ pathname: "/search" })}
@@ -132,6 +137,11 @@ export default function HomeScreen() {
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost"> <StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} /> <Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton> </StyledButton>
{isLoggedIn && (
<StyledButton style={styles.searchButton} onPress={logout} variant="ghost">
<LogOut color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
)}
</View> </View>
</View> </View>
@@ -236,9 +246,7 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
}, },
searchButton: { searchButton: {
padding: 10,
borderRadius: 30, borderRadius: 30,
marginLeft: 10,
}, },
// Category Selector // Category Selector
categoryContainer: { categoryContainer: {

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react"; 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 { useLocalSearchParams, useRouter } from "expo-router";
import { Video, ResizeMode } from "expo-av"; import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from "expo-keep-awake"; import { useKeepAwake } from "expo-keep-awake";
@@ -10,50 +10,74 @@ import { SourceSelectionModal } from "@/components/SourceSelectionModal";
import { SeekingBar } from "@/components/SeekingBar"; import { SeekingBar } from "@/components/SeekingBar";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay"; import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import { LoadingOverlay } from "@/components/LoadingOverlay"; import { LoadingOverlay } from "@/components/LoadingOverlay";
import usePlayerStore from "@/stores/playerStore"; import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler"; import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
import Toast from "react-native-toast-message";
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
export default function PlayScreen() { export default function PlayScreen() {
const videoRef = useRef<Video>(null); const videoRef = useRef<Video>(null);
const router = useRouter(); const router = useRouter();
useKeepAwake(); useKeepAwake();
const { source, id, episodeIndex, position } = useLocalSearchParams<{
source: string;
id: string;
episodeIndex: string;
position: string;
}>();
const { const {
detail, episodeIndex: episodeIndexStr,
episodes, position: positionStr,
currentEpisodeIndex, 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, isLoading,
showControls, showControls,
showEpisodeModal,
showSourceModal,
showNextEpisodeOverlay, showNextEpisodeOverlay,
initialPosition, initialPosition,
introEndTime, introEndTime,
setVideoRef, setVideoRef,
loadVideo,
handlePlaybackStatusUpdate, handlePlaybackStatusUpdate,
setShowControls, setShowControls,
setShowEpisodeModal,
setShowSourceModal,
setShowNextEpisodeOverlay, setShowNextEpisodeOverlay,
reset, reset,
loadVideo,
} = usePlayerStore(); } = usePlayerStore();
const currentEpisode = usePlayerStore(selectCurrentEpisode);
useEffect(() => { useEffect(() => {
setVideoRef(videoRef); setVideoRef(videoRef);
if (source && id) { if (source && id && title) {
loadVideo(source, id, parseInt(episodeIndex || "0", 10), parseInt(position || "0", 10)); loadVideo({ source, id, episodeIndex, position, title });
} }
return () => { return () => {
reset(); // Reset state when component unmounts 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(); const { onScreenPress } = useTVRemoteHandler();
@@ -70,17 +94,9 @@ export default function PlayScreen() {
const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction); const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction);
return () => backHandler.remove(); return () => backHandler.remove();
}, [ }, [showControls, setShowControls, router]);
showControls,
showEpisodeModal,
showSourceModal,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
router,
]);
if (!detail && isLoading) { if (!detail) {
return ( return (
<ThemedView style={[styles.container, styles.centered]}> <ThemedView style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#fff" /> <ActivityIndicator size="large" color="#fff" />
@@ -88,26 +104,28 @@ export default function PlayScreen() {
); );
} }
const currentEpisode = episodes[currentEpisodeIndex];
return ( return (
<ThemedView focusable style={styles.container}> <ThemedView focusable style={styles.container}>
<TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}> <TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}>
<Video <Video
ref={videoRef} ref={videoRef}
style={styles.videoPlayer} style={styles.videoPlayer}
source={{ uri: currentEpisode?.url }} source={{ uri: currentEpisode?.url || "" }}
usePoster usePoster
posterSource={{ uri: detail?.videoInfo.cover ?? "" }} posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN} resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate} onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => { onLoad={() => {
const jumpPosition = introEndTime || initialPosition; const jumpPosition = initialPosition || introEndTime || 0;
if (jumpPosition > 0) { if (jumpPosition > 0) {
videoRef.current?.setPositionAsync(jumpPosition); videoRef.current?.setPositionAsync(jumpPosition);
} }
usePlayerStore.setState({ isLoading: false }); usePlayerStore.setState({ isLoading: false });
}} }}
onError={() => {
usePlayerStore.setState({ isLoading: false });
Toast.show({ type: "error", text1: "播放失败,请更换源后重试" });
}}
onLoadStart={() => usePlayerStore.setState({ isLoading: true })} onLoadStart={() => usePlayerStore.setState({ isLoading: true })}
useNativeControls={false} useNativeControls={false}
shouldPlay shouldPlay

View File

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

View File

@@ -6,6 +6,7 @@ import { ThemedView } from "@/components/ThemedView";
import { StyledButton } from "@/components/StyledButton"; import { StyledButton } from "@/components/StyledButton";
import { useThemeColor } from "@/hooks/useThemeColor"; import { useThemeColor } from "@/hooks/useThemeColor";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import useAuthStore from "@/stores/authStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore"; import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { APIConfigSection } from "@/components/settings/APIConfigSection"; import { APIConfigSection } from "@/components/settings/APIConfigSection";
import { LiveStreamSection } from "@/components/settings/LiveStreamSection"; import { LiveStreamSection } from "@/components/settings/LiveStreamSection";
@@ -16,6 +17,7 @@ import Toast from "react-native-toast-message";
export default function SettingsScreen() { export default function SettingsScreen() {
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore(); const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
const { lastMessage } = useRemoteControlStore(); const { lastMessage } = useRemoteControlStore();
const { isLoggedIn, logout } = useAuthStore();
const backgroundColor = useThemeColor({}, "background"); const backgroundColor = useThemeColor({}, "background");
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);

View File

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

View File

@@ -54,7 +54,7 @@ let cachedConfig: Config;
try { try {
cachedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as Config; cachedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as Config;
} catch (error) { } 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 // Provide a default fallback config to prevent crashes
cachedConfig = { cachedConfig = {
api_site: {}, 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 // Match m3u8 links
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g; const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
async function handleSpecialSourceDetail( async function handleSpecialSourceDetail(id: string, apiSite: ApiSite): Promise<VideoDetail> {
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`; const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); const timeoutId = setTimeout(() => controller.abort(), 10000);
@@ -30,8 +27,7 @@ async function handleSpecialSourceDetail(
let matches: string[] = []; let matches: string[] = [];
if (apiSite.key === "ffzy") { if (apiSite.key === "ffzy") {
const ffzyPattern = const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || []; matches = html.match(ffzyPattern) || [];
} }
@@ -48,32 +44,22 @@ async function handleSpecialSourceDetail(
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/); const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : ""; const titleText = titleMatch ? titleMatch[1].trim() : "";
const descMatch = html.match( const descMatch = html.match(/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : ""; const descText = descMatch ? cleanHtmlTags(descMatch[1]) : "";
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g); const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : ""; const coverUrl = coverMatch ? coverMatch[0].trim() : "";
return { return {
code: 200, id,
episodes: matches, title: titleText,
detailUrl, poster: coverUrl,
videoInfo: { desc: descText,
title: titleText, source_name: apiSite.name,
cover: coverUrl, source: apiSite.key,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id,
},
}; };
} }
async function getDetailFromApi( async function getDetailFromApi(apiSite: ApiSite, id: string): Promise<VideoDetail> {
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`; const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); const timeoutId = setTimeout(() => controller.abort(), 10000);
@@ -89,12 +75,7 @@ async function getDetailFromApi(
} }
const data = await response.json(); const data = await response.json();
if ( if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error("获取到的详情内容无效"); throw new Error("获取到的详情内容无效");
} }
@@ -111,10 +92,7 @@ async function getDetailFromApi(
const parts = ep.split("$"); const parts = ep.split("$");
return parts.length > 1 ? parts[1] : ""; return parts.length > 1 ? parts[1] : "";
}) })
.filter( .filter((url: string) => url && (url.startsWith("http://") || url.startsWith("https://")));
(url: string) =>
url && (url.startsWith("http://") || url.startsWith("https://"))
);
} }
} }
@@ -124,30 +102,22 @@ async function getDetailFromApi(
} }
return { return {
code: 200, id,
episodes, title: videoDetail.vod_name,
detailUrl, poster: videoDetail.vod_pic,
videoInfo: { desc: cleanHtmlTags(videoDetail.vod_content),
title: videoDetail.vod_name, type: videoDetail.type_name,
cover: videoDetail.vod_pic, year: videoDetail.vod_year?.match(/\d{4}/)?.[0] || "",
desc: cleanHtmlTags(videoDetail.vod_content), area: videoDetail.vod_area,
type: videoDetail.type_name, director: videoDetail.vod_director,
year: videoDetail.vod_year?.match(/\d{4}/)?.[0] || "", actor: videoDetail.vod_actor,
area: videoDetail.vod_area, remarks: videoDetail.vod_remarks,
director: videoDetail.vod_director, source_name: apiSite.name,
actor: videoDetail.vod_actor, source: apiSite.key,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
id,
},
}; };
} }
async function getVideoDetail( async function getVideoDetail(id: string, sourceCode: string): Promise<VideoDetail> {
id: string,
sourceCode: string
): Promise<VideoDetail> {
if (!id) { if (!id) {
throw new Error("缺少视频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"); res.status(500).send("Image response has no body");
} }
} catch (error) { } catch (error) {
console.error("Image proxy error:", error); console.info("Image proxy error:", error);
res.status(500).send("Error fetching image"); res.status(500).send("Error fetching image");
} }
}); });

View File

@@ -3,9 +3,19 @@ import searchRouter from "./search";
import detailRouter from "./detail"; import detailRouter from "./detail";
import doubanRouter from "./douban"; import doubanRouter from "./douban";
import imageProxyRouter from "./image-proxy"; 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(); const router = Router();
router.use(serverConfigRouter);
router.use(loginRouter);
router.use(favoritesRouter);
router.use(playRecordsRouter);
router.use(searchHistoryRouter);
router.use("/search", searchRouter); router.use("/search", searchRouter);
router.use("/detail", detailRouter); router.use("/detail", detailRouter);
router.use("/douban", doubanRouter); 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 { export interface PlayRecord {
title: string; title: string;
source_name: string; source_name: string;
cover: string; poster: string;
index: number; // Episode number index: number; // Episode number
total_episodes: number; // Total number of episodes total_episodes: number; // Total number of episodes
play_time: number; // Play progress in seconds play_time: number; // Play progress in seconds
@@ -13,21 +13,16 @@ export interface PlayRecord {
// You can add other shared types here // You can add other shared types here
export interface VideoDetail { export interface VideoDetail {
code: number; id: string;
episodes: string[]; title: string;
detailUrl: string; poster: string;
videoInfo: { source: string;
title: string; source_name: string;
cover: string; desc?: string;
desc: string; type?: string;
source_name: string; year?: string;
source: string; area?: string;
id: string; director?: string;
type?: string; actor?: string;
year?: string; remarks?: 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 { MediaButton } from "@/components/MediaButton";
import usePlayerStore from "@/stores/playerStore"; import usePlayerStore from "@/stores/playerStore";
import useDetailStore from "@/stores/detailStore";
import { useSources } from "@/stores/sourceStore";
interface PlayerControlsProps { interface PlayerControlsProps {
showControls: boolean; showControls: boolean;
@@ -13,9 +15,8 @@ interface PlayerControlsProps {
export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => { export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => {
const { const {
detail,
currentEpisodeIndex, currentEpisodeIndex,
currentSourceIndex, episodes,
status, status,
isSeeking, isSeeking,
seekPosition, seekPosition,
@@ -30,12 +31,15 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
outroStartTime, outroStartTime,
} = usePlayerStore(); } = usePlayerStore();
const videoTitle = detail?.videoInfo?.title || ""; const { detail } = useDetailStore();
const currentEpisode = detail?.episodes[currentEpisodeIndex]; const resources = useSources();
const videoTitle = detail?.title || "";
const currentEpisode = episodes[currentEpisodeIndex];
const currentEpisodeTitle = currentEpisode?.title; const currentEpisodeTitle = currentEpisode?.title;
const currentSource = detail?.sources[currentSourceIndex]; const currentSource = resources.find((r) => r.source === detail?.source);
const currentSourceName = currentSource?.source_name; const currentSourceName = currentSource?.source_name;
const hasNextEpisode = currentEpisodeIndex < (detail?.episodes.length || 0) - 1; const hasNextEpisode = currentEpisodeIndex < (episodes.length || 0) - 1;
const formatTime = (milliseconds: number) => { const formatTime = (milliseconds: number) => {
if (!milliseconds) return "00:00"; if (!milliseconds) return "00:00";

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useCallback } from "react";
import { StyleSheet, View, Switch, ActivityIndicator, FlatList, Pressable, Animated } from "react-native"; import { StyleSheet, Switch, FlatList, Pressable, Animated } from "react-native";
import { useTVEventHandler } from "react-native"; import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection"; import { SettingsSection } from "./SettingsSection";
import { api, ApiSite } from "@/services/api";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import useSourceStore, { useSources } from "@/stores/sourceStore";
interface VideoSourceSectionProps { interface VideoSourceSectionProps {
onChanged: () => void; onChanged: () => void;
@@ -13,56 +13,18 @@ interface VideoSourceSectionProps {
} }
export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChanged, onFocus, onBlur }) => { 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 [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [isSectionFocused, setIsSectionFocused] = useState(false); const [isSectionFocused, setIsSectionFocused] = useState(false);
const { videoSource, setVideoSource } = useSettingsStore(); const { videoSource } = useSettingsStore();
const resources = useSources();
const { toggleResourceEnabled } = useSourceStore();
useEffect(() => { const handleToggle = useCallback(
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(
(resourceKey: string) => { (resourceKey: string) => {
const isEnabled = videoSource.sources[resourceKey]; toggleResourceEnabled(resourceKey);
const newEnabledSources = { ...videoSource.sources, [resourceKey]: !isEnabled };
setVideoSource({
enabledAll: Object.values(newEnabledSources).every((enabled) => enabled),
sources: newEnabledSources,
});
onChanged(); onChanged();
}, },
[videoSource.sources, setVideoSource, onChanged] [onChanged, toggleResourceEnabled]
); );
const handleSectionFocus = () => { const handleSectionFocus = () => {
@@ -83,20 +45,20 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
if (focusedIndex !== null) { if (focusedIndex !== null) {
const resource = resources[focusedIndex]; const resource = resources[focusedIndex];
if (resource) { if (resource) {
toggleResourceEnabled(resource.key); handleToggle(resource.source);
} }
} else if (isSectionFocused) { } else if (isSectionFocused) {
setFocusedIndex(0); setFocusedIndex(0);
} }
} }
}, },
[isSectionFocused, focusedIndex, resources, toggleResourceEnabled] [isSectionFocused, focusedIndex, resources, handleToggle]
); );
useTVEventHandler(handleTVEvent); useTVEventHandler(handleTVEvent);
const renderResourceItem = ({ item, index }: { item: ApiSite; index: number }) => { const renderResourceItem = ({ item, index }: { item: { source: string; source_name: string }; index: number }) => {
const isEnabled = videoSource.enabledAll || videoSource.sources[item.key]; const isEnabled = videoSource.enabledAll || videoSource.sources[item.source];
const isFocused = focusedIndex === index; const isFocused = focusedIndex === index;
return ( return (
@@ -107,7 +69,7 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
onFocus={() => setFocusedIndex(index)} onFocus={() => setFocusedIndex(index)}
onBlur={() => setFocusedIndex(null)} onBlur={() => setFocusedIndex(null)}
> >
<ThemedText style={styles.resourceName}>{item.name}</ThemedText> <ThemedText style={styles.resourceName}>{item.source_name}</ThemedText>
<Switch <Switch
value={isEnabled} value={isEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互 onValueChange={() => {}} // 禁用Switch的直接交互
@@ -124,20 +86,11 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}> <SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<ThemedText style={styles.sectionTitle}></ThemedText> <ThemedText style={styles.sectionTitle}></ThemedText>
{loading && ( {resources.length > 0 && (
<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 && (
<FlatList <FlatList
data={resources} data={resources}
renderItem={renderResourceItem} renderItem={renderResourceItem}
keyExtractor={(item) => item.key} keyExtractor={(item) => item.source}
numColumns={3} numColumns={3}
columnWrapperStyle={styles.row} columnWrapperStyle={styles.row}
contentContainerStyle={styles.flatListContainer} contentContainerStyle={styles.flatListContainer}
@@ -154,22 +107,6 @@ const styles = StyleSheet.create({
fontWeight: "bold", fontWeight: "bold",
marginBottom: 16, 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: { flatListContainer: {
gap: 12, 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", "name": "OrionTV",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.1.2", "version": "1.2.1",
"scripts": { "scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start", "start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 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", "@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^11.3.2", "@react-native-community/netinfo": "^11.3.2",
"@react-native-cookies/cookies": "^6.2.1",
"@react-navigation/native": "^6.0.2", "@react-navigation/native": "^6.0.2",
"expo": "~51.0.13", "expo": "~51.0.13",
"expo-av": "~14.0.7", "expo-av": "~14.0.7",

View File

@@ -1,5 +1,5 @@
import { SettingsManager } from "./storage";
// region: --- Interface Definitions ---
export interface DoubanItem { export interface DoubanItem {
title: string; title: string;
poster: string; poster: string;
@@ -13,23 +13,18 @@ export interface DoubanResponse {
} }
export interface VideoDetail { export interface VideoDetail {
code: number; id: string;
episodes: string[]; title: string;
detailUrl: string; poster: string;
videoInfo: { source: string;
title: string; source_name: string;
cover?: string; desc?: string;
desc?: string; type?: string;
type?: string; year?: string;
year?: string; area?: string;
area?: string; director?: string;
director?: string; actor?: string;
actor?: string; remarks?: string;
remarks?: string;
source_name: string;
source: string;
id: string;
};
} }
export interface SearchResult { export interface SearchResult {
@@ -45,17 +40,27 @@ export interface SearchResult {
type_name?: string; 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 { export interface PlayRecord {
title: string; title: string;
source_name: string; source_name: string;
cover: string; cover: string;
index: number; // Episode number index: number;
total_episodes: number; // Total number of episodes total_episodes: number;
play_time: number; // Play progress in seconds play_time: number;
total_time: number; // Total duration in seconds total_time: number;
save_time: number; // Timestamp of when the record was saved save_time: number;
user_id: number; // User ID, always 0 in this version year: string;
} }
export interface ApiSite { export interface ApiSite {
@@ -65,6 +70,11 @@ export interface ApiSite {
detail?: string; detail?: string;
} }
export interface ServerConfig {
SiteName: string;
StorageType: "localstorage" | "redis" | string;
}
export class API { export class API {
public baseURL: string = ""; public baseURL: string = "";
@@ -78,91 +88,138 @@ export class API {
this.baseURL = url; this.baseURL = url;
} }
/** private async _fetch(url: string, options: RequestInit = {}): Promise<Response> {
* 生成图片代理 URL if (!this.baseURL) {
*/ throw new Error("API_URL_NOT_SET");
getImageProxyUrl(imageUrl: string): string { }
return `${this.baseURL}/api/image-proxy?url=${encodeURIComponent(
imageUrl 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( async getDoubanData(
type: "movie" | "tv", type: "movie" | "tv",
tag: string, tag: string,
pageSize: number = 16, pageSize: number = 16,
pageStart: number = 0 pageStart: number = 0
): Promise<DoubanResponse> { ): Promise<DoubanResponse> {
if (!this.baseURL) { const url = `/api/douban?type=${type}&tag=${encodeURIComponent(tag)}&pageSize=${pageSize}&pageStart=${pageStart}`;
throw new Error("API_URL_NOT_SET"); const response = await this._fetch(url);
}
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}`);
return response.json(); return response.json();
} }
/**
* 搜索视频
*/
async searchVideos(query: string): Promise<{ results: SearchResult[] }> { async searchVideos(query: string): Promise<{ results: SearchResult[] }> {
if (!this.baseURL) { const url = `/api/search?q=${encodeURIComponent(query)}`;
throw new Error("API_URL_NOT_SET"); const response = await this._fetch(url);
}
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}`);
return response.json(); return response.json();
} }
async searchVideo(query: string, resourceId: string, signal?: AbortSignal): Promise<{ results: SearchResult[] }> { async searchVideo(query: string, resourceId: string, signal?: AbortSignal): Promise<{ results: SearchResult[] }> {
if (!this.baseURL) { const url = `/api/search/one?q=${encodeURIComponent(query)}&resourceId=${encodeURIComponent(resourceId)}`;
throw new Error("API_URL_NOT_SET"); const response = await this._fetch(url, { signal });
}
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}`);
return response.json(); return response.json();
} }
async getResources(signal?: AbortSignal): Promise<ApiSite[]> { async getResources(signal?: AbortSignal): Promise<ApiSite[]> {
if (!this.baseURL) { const url = `/api/search/resources`;
throw new Error("API_URL_NOT_SET"); const response = await this._fetch(url, { signal });
}
const url = `${this.baseURL}/api/search/resources`;
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json(); return response.json();
} }
/**
* 获取视频详情
*/
async getVideoDetail(source: string, id: string): Promise<VideoDetail> { async getVideoDetail(source: string, id: string): Promise<VideoDetail> {
if (!this.baseURL) { const url = `/api/detail?source=${source}&id=${id}`;
throw new Error("API_URL_NOT_SET"); const response = await this._fetch(url);
}
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}`);
return response.json(); return response.json();
} }
} }
// 默认实例 // 默认实例
export let api = new API(); 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(); const m3uText = await response.text();
return parseM3U(m3uText); return parseM3U(m3uText);
} catch (error) { } catch (error) {
console.error("Error fetching or parsing M3U:", error); console.info("Error fetching or parsing M3U:", error);
return []; // Return empty array on error return []; // Return empty array on error
} }
}; };

View File

@@ -1,4 +1,4 @@
import TCPHttpServer from './tcpHttpServer'; import TCPHttpServer from "./tcpHttpServer";
const getRemotePageHTML = () => { const getRemotePageHTML = () => {
return ` return `
@@ -6,6 +6,7 @@ const getRemotePageHTML = () => {
<html> <html>
<head> <head>
<title>OrionTV Remote</title> <title>OrionTV Remote</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style> <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; } 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> </div>
<script> <script>
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
fetch('/handshake', { method: 'POST' }).catch(console.error); fetch('/handshake', { method: 'POST' }).catch(console.info);
}); });
function send() { function send() {
const input = document.getElementById("text"); const input = document.getElementById("text");
@@ -35,7 +36,7 @@ const getRemotePageHTML = () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value }) body: JSON.stringify({ message: value })
}) })
.catch(err => console.error(err)); .catch(err => console.info(err));
input.value = ''; input.value = '';
} }
} }
@@ -57,55 +58,55 @@ class RemoteControlService {
private setupRequestHandler() { private setupRequestHandler() {
this.httpServer.setRequestHandler((request) => { this.httpServer.setRequestHandler((request) => {
console.log('[RemoteControl] Received request:', request.method, request.url); console.log("[RemoteControl] Received request:", request.method, request.url);
try { try {
if (request.method === 'GET' && request.url === '/') { if (request.method === "GET" && request.url === "/") {
return { return {
statusCode: 200, statusCode: 200,
headers: { 'Content-Type': 'text/html' }, headers: { "Content-Type": "text/html; charset=utf-8" },
body: getRemotePageHTML() body: getRemotePageHTML(),
}; };
} else if (request.method === 'POST' && request.url === '/message') { } else if (request.method === "POST" && request.url === "/message") {
try { try {
const parsedBody = JSON.parse(request.body || '{}'); const parsedBody = JSON.parse(request.body || "{}");
const message = parsedBody.message; const message = parsedBody.message;
if (message) { if (message) {
this.onMessage(message); this.onMessage(message);
} }
return { return {
statusCode: 200, statusCode: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: 'ok' }) body: JSON.stringify({ status: "ok" }),
}; };
} catch (parseError) { } catch (parseError) {
console.error('[RemoteControl] Failed to parse message body:', parseError); console.info("[RemoteControl] Failed to parse message body:", parseError);
return { return {
statusCode: 400, statusCode: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: 'Invalid 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(); this.onHandshake();
return { return {
statusCode: 200, statusCode: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: 'ok' }) body: JSON.stringify({ status: "ok" }),
}; };
} else { } else {
return { return {
statusCode: 404, statusCode: 404,
headers: { 'Content-Type': 'text/plain' }, headers: { "Content-Type": "text/plain" },
body: 'Not Found' body: "Not Found",
}; };
} }
} catch (error) { } catch (error) {
console.error('[RemoteControl] Request handler error:', error); console.info("[RemoteControl] Request handler error:", error);
return { return {
statusCode: 500, statusCode: 500,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: 'Internal Server Error' }) body: JSON.stringify({ error: "Internal Server Error" }),
}; };
} }
}); });
@@ -117,20 +118,20 @@ class RemoteControlService {
} }
public async startServer(): Promise<string> { public async startServer(): Promise<string> {
console.log('[RemoteControl] Attempting to start server...'); console.log("[RemoteControl] Attempting to start server...");
try { try {
const url = await this.httpServer.start(); const url = await this.httpServer.start();
console.log(`[RemoteControl] Server started successfully at: ${url}`); console.log(`[RemoteControl] Server started successfully at: ${url}`);
return url; return url;
} catch (error) { } catch (error) {
console.error('[RemoteControl] Failed to start server:', error); console.info("[RemoteControl] Failed to start server:", error);
throw new Error(error instanceof Error ? error.message : 'Failed to start server'); throw new Error(error instanceof Error ? error.message : "Failed to start server");
} }
} }
public stopServer() { public stopServer() {
console.log('[RemoteControl] Stopping server...'); console.log("[RemoteControl] Stopping server...");
this.httpServer.stop(); 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 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 --- // --- Storage Keys ---
const STORAGE_KEYS = { const STORAGE_KEYS = {
SETTINGS: "mytv_settings",
PLAYER_SETTINGS: "mytv_player_settings",
FAVORITES: "mytv_favorites", FAVORITES: "mytv_favorites",
PLAY_RECORDS: "mytv_play_records", PLAY_RECORDS: "mytv_play_records",
SEARCH_HISTORY: "mytv_search_history", SEARCH_HISTORY: "mytv_search_history",
SETTINGS: "mytv_settings",
} as const; } as const;
// --- Type Definitions (aligned with api.ts) --- // --- 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; introEndTime?: number;
outroStartTime?: number; outroStartTime?: number;
} };
export type Favorite = ApiFavorite;
export interface FavoriteItem { export interface PlayerSettings {
id: string; introEndTime?: number;
source: string; outroStartTime?: number;
title: string;
poster: string;
source_name: string;
save_time: number;
} }
export interface AppSettings { export interface AppSettings {
@@ -32,59 +32,106 @@ export interface AppSettings {
sources: { sources: {
[key: string]: boolean; [key: string]: boolean;
}; };
}, };
m3uUrl: string; m3uUrl: string;
} }
// --- Helper --- // --- Helper ---
const generateKey = (source: string, id: string) => `${source}+${id}`; const generateKey = (source: string, id: string) => `${source}+${id}`;
// --- FavoriteManager --- // --- PlayerSettingsManager (Uses AsyncStorage) ---
export class FavoriteManager { export class PlayerSettingsManager {
static async getAll(): Promise<Record<string, FavoriteItem>> { static async getAll(): Promise<Record<string, PlayerSettings>> {
try { try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES); const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAYER_SETTINGS);
return data ? JSON.parse(data) : {}; return data ? JSON.parse(data) : {};
} catch (error) { } catch (error) {
console.error("Failed to get favorites:", error); console.info("Failed to get all player settings:", error);
return {}; return {};
} }
} }
static async save( static async get(source: string, id: string): Promise<PlayerSettings | null> {
source: string, const allSettings = await this.getAll();
id: string, return allSettings[generateKey(source, id)] || null;
item: Omit<FavoriteItem, "id" | "source" | "save_time"> }
): Promise<void> {
const favorites = await this.getAll(); static async save(source: string, id: string, settings: PlayerSettings): Promise<void> {
const allSettings = await this.getAll();
const key = generateKey(source, id); const key = generateKey(source, id);
favorites[key] = { ...item, id, source, save_time: Date.now() }; // Only save if there are actual values to save
await AsyncStorage.setItem( if (settings.introEndTime !== undefined || settings.outroStartTime !== undefined) {
STORAGE_KEYS.FAVORITES, allSettings[key] = { ...allSettings[key], ...settings };
JSON.stringify(favorites) } 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> { 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); const key = generateKey(source, id);
delete favorites[key]; if (this.getStorageType() === "localstorage") {
await AsyncStorage.setItem( const allFavorites = await this.getAll();
STORAGE_KEYS.FAVORITES, allFavorites[key] = { ...item, save_time: Date.now() };
JSON.stringify(favorites) 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> { static async isFavorited(source: string, id: string): Promise<boolean> {
const favorites = await this.getAll(); const key = generateKey(source, id);
return generateKey(source, id) in favorites; if (this.getStorageType() === "localstorage") {
const allFavorites = await this.getAll();
return !!allFavorites[key];
}
const favorite = await api.getFavorites(key);
return favorite !== null;
} }
static async toggle( static async toggle(source: string, id: string, item: Favorite): Promise<boolean> {
source: string,
id: string,
item: Omit<FavoriteItem, "id" | "source" | "save_time">
): Promise<boolean> {
const isFav = await this.isFavorited(source, id); const isFav = await this.isFavorited(source, id);
if (isFav) { if (isFav) {
await this.remove(source, id); await this.remove(source, id);
@@ -96,90 +143,134 @@ export class FavoriteManager {
} }
static async clearAll(): Promise<void> { 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 { export class PlayRecordManager {
static async getAll(): Promise<Record<string, PlayRecord>> { private static getStorageType() {
try { return storageConfig.getStorageType();
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 {};
}
} }
static async save( static async getAll(): Promise<Record<string, PlayRecord>> {
source: string, let apiRecords: Record<string, PlayRecord> = {};
id: string, if (this.getStorageType() === "localstorage") {
record: Omit<PlayRecord, "user_id" | "save_time"> try {
): Promise<void> { const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAY_RECORDS);
const records = await this.getAll(); 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); const key = generateKey(source, id);
records[key] = { ...record, user_id: 0, save_time: Date.now() }; const { introEndTime, outroStartTime, ...apiRecord } = record;
await AsyncStorage.setItem(
STORAGE_KEYS.PLAY_RECORDS, // Player settings are always saved locally
JSON.stringify(records) 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> { static async get(source: string, id: string): Promise<PlayRecord | null> {
const key = generateKey(source, id);
const records = await this.getAll(); const records = await this.getAll();
return records[generateKey(source, id)] || null; return records[key] || null;
} }
static async remove(source: string, id: string): Promise<void> { static async remove(source: string, id: string): Promise<void> {
const records = await this.getAll(); const key = generateKey(source, id);
delete records[generateKey(source, id)]; await PlayerSettingsManager.remove(source, id); // Always remove local settings
await AsyncStorage.setItem(
STORAGE_KEYS.PLAY_RECORDS, if (this.getStorageType() === "localstorage") {
JSON.stringify(records) 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> { 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 --- // --- SearchHistoryManager (Dynamic: API or LocalStorage) ---
const SEARCH_HISTORY_LIMIT = 20;
export class SearchHistoryManager { export class SearchHistoryManager {
private static getStorageType() {
return storageConfig.getStorageType();
}
static async get(): Promise<string[]> { static async get(): Promise<string[]> {
try { if (this.getStorageType() === "localstorage") {
const data = await AsyncStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY); try {
return data ? JSON.parse(data) : []; const data = await AsyncStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY);
} catch (error) { return data ? JSON.parse(data) : [];
console.error("Failed to get search history:", error); } catch (error) {
return []; console.info("Failed to get local search history:", error);
return [];
}
} }
return api.getSearchHistory();
} }
static async add(keyword: string): Promise<void> { static async add(keyword: string): Promise<void> {
const trimmed = keyword.trim(); const trimmed = keyword.trim();
if (!trimmed) return; if (!trimmed) return;
const history = await this.get(); if (this.getStorageType() === "localstorage") {
const newHistory = [trimmed, ...history.filter((k) => k !== trimmed)]; let history = await this.get();
if (newHistory.length > SEARCH_HISTORY_LIMIT) { history = [trimmed, ...history.filter((k) => k !== trimmed)].slice(0, 20); // Keep latest 20
newHistory.length = SEARCH_HISTORY_LIMIT; await AsyncStorage.setItem(STORAGE_KEYS.SEARCH_HISTORY, JSON.stringify(history));
return;
} }
await AsyncStorage.setItem( await api.addSearchHistory(trimmed);
STORAGE_KEYS.SEARCH_HISTORY,
JSON.stringify(newHistory)
);
} }
static async clear(): Promise<void> { 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 { export class SettingsManager {
static async get(): Promise<AppSettings> { static async get(): Promise<AppSettings> {
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
@@ -189,15 +280,13 @@ export class SettingsManager {
enabledAll: true, enabledAll: true,
sources: {}, sources: {},
}, },
m3uUrl: "https://ghfast.top/https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u", m3uUrl: "",
}; };
try { try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.SETTINGS); const data = await AsyncStorage.getItem(STORAGE_KEYS.SETTINGS);
return data return data ? { ...defaultSettings, ...JSON.parse(data) } : defaultSettings;
? { ...defaultSettings, ...JSON.parse(data) }
: defaultSettings;
} catch (error) { } catch (error) {
console.error("Failed to get settings:", error); console.info("Failed to get settings:", error);
return defaultSettings; return defaultSettings;
} }
} }
@@ -205,10 +294,7 @@ export class SettingsManager {
static async save(settings: Partial<AppSettings>): Promise<void> { static async save(settings: Partial<AppSettings>): Promise<void> {
const currentSettings = await this.get(); const currentSettings = await this.get();
const updatedSettings = { ...currentSettings, ...settings }; const updatedSettings = { ...currentSettings, ...settings };
await AsyncStorage.setItem( await AsyncStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(updatedSettings));
STORAGE_KEYS.SETTINGS,
JSON.stringify(updatedSettings)
);
} }
static async reset(): Promise<void> { 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 }; return { method, url, headers, body };
} catch (error) { } catch (error) {
console.error('[TCPHttpServer] Error parsing HTTP request:', error); console.info('[TCPHttpServer] Error parsing HTTP request:', error);
return null; return null;
} }
} }
@@ -140,7 +140,7 @@ class TCPHttpServer {
socket.write(errorResponse); socket.write(errorResponse);
} }
} catch (error) { } catch (error) {
console.error('[TCPHttpServer] Error handling request:', error); console.info('[TCPHttpServer] Error handling request:', error);
const errorResponse = this.formatHttpResponse({ const errorResponse = this.formatHttpResponse({
statusCode: 500, statusCode: 500,
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain' },
@@ -155,7 +155,7 @@ class TCPHttpServer {
}); });
socket.on('error', (error: Error) => { socket.on('error', (error: Error) => {
console.error('[TCPHttpServer] Socket error:', error); console.info('[TCPHttpServer] Socket error:', error);
}); });
socket.on('close', () => { socket.on('close', () => {
@@ -170,13 +170,13 @@ class TCPHttpServer {
}); });
this.server.on('error', (error: Error) => { this.server.on('error', (error: Error) => {
console.error('[TCPHttpServer] Server error:', error); console.info('[TCPHttpServer] Server error:', error);
this.isRunning = false; this.isRunning = false;
reject(error); reject(error);
}); });
} catch (error) { } catch (error) {
console.error('[TCPHttpServer] Failed to start server:', error); console.info('[TCPHttpServer] Failed to start server:', error);
reject(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 { create } from "zustand";
import { api, SearchResult, PlayRecord } from '@/services/api'; import { api, SearchResult, PlayRecord } from "@/services/api";
import { PlayRecordManager } from '@/services/storage'; import { PlayRecordManager } from "@/services/storage";
import useAuthStore from "./authStore";
import { useSettingsStore } from "./settingsStore";
export type RowItem = (SearchResult | PlayRecord) & { export type RowItem = (SearchResult | PlayRecord) & {
id: string; id: string;
@@ -19,18 +21,38 @@ export type RowItem = (SearchResult | PlayRecord) & {
export interface Category { export interface Category {
title: string; title: string;
type?: 'movie' | 'tv' | 'record'; type?: "movie" | "tv" | "record";
tag?: string; tag?: string;
tags?: string[]; tags?: string[];
} }
const initialCategories: Category[] = [ const initialCategories: Category[] = [
{ title: '最近播放', type: 'record' }, { title: "最近播放", type: "record" },
{ title: '热门剧集', type: 'tv', tag: '热门' }, { title: "热门剧集", type: "tv", tag: "热门" },
{ title: '电视剧', type: 'tv', tags: [ '国产剧', '美剧', '英剧', '韩剧', '日剧', '港剧', '日本动画', '动画'] }, { title: "电视剧", type: "tv", tags: ["国产剧", "美剧", "英剧", "韩剧", "日剧", "港剧", "日本动画", "动画"] },
{ title: '电影', type: 'movie', tags: ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '爱情', '科幻', '悬疑', '恐怖'] }, {
{ title: '综艺', type: 'tv', tag: '综艺' }, title: "电影",
{ title: '豆瓣 Top250', type: 'movie', tag: 'top250' }, type: "movie",
tags: [
"热门",
"最新",
"经典",
"豆瓣高分",
"冷门佳片",
"华语",
"欧美",
"韩国",
"日本",
"动作",
"喜剧",
"爱情",
"科幻",
"悬疑",
"恐怖",
],
},
{ title: "综艺", type: "tv", tag: "综艺" },
{ title: "豆瓣 Top250", type: "movie", tag: "top250" },
]; ];
interface HomeState { interface HomeState {
@@ -59,6 +81,8 @@ const useHomeStore = create<HomeState>((set, get) => ({
error: null, error: null,
fetchInitialData: async () => { fetchInitialData: async () => {
const { apiBaseUrl } = useSettingsStore.getState();
await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null }); set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData(); await get().loadMoreData();
}, },
@@ -72,28 +96,44 @@ const useHomeStore = create<HomeState>((set, get) => ({
} }
try { 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 records = await PlayRecordManager.getAll();
const rowItems = Object.entries(records) const rowItems = Object.entries(records)
.map(([key, record]) => { .map(([key, record]) => {
const [source, id] = key.split('+'); 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 }; 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)); .sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
set({ contentData: rowItems, hasMore: false }); set({ contentData: rowItems, hasMore: false });
} else if (selectedCategory.type && selectedCategory.tag) { } else if (selectedCategory.type && selectedCategory.tag) {
const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart); const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart);
if (result.list.length === 0) { if (result.list.length === 0) {
set({ hasMore: false }); set({ hasMore: false });
} else { } else {
const newItems = result.list.map(item => ({ const newItems = result.list.map((item) => ({
...item, ...item,
id: item.title, id: item.title,
source: 'douban', source: "douban",
})) as RowItem[]; })) as RowItem[];
set(state => ({ set((state) => ({
contentData: pageStart === 0 ? newItems : [...state.contentData, ...newItems], contentData: pageStart === 0 ? newItems : [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length, pageStart: state.pageStart + result.list.length,
hasMore: true, hasMore: true,
@@ -106,10 +146,10 @@ const useHomeStore = create<HomeState>((set, get) => ({
set({ hasMore: false }); set({ hasMore: false });
} }
} catch (err: any) { } catch (err: any) {
if (err.message === 'API_URL_NOT_SET') { if (err.message === "API_URL_NOT_SET") {
set({ error: '请点击右上角设置按钮,配置您的 API 地址' }); set({ error: "请点击右上角设置按钮,配置您的 API 地址" });
} else { } else {
set({ error: '加载失败,请重试' }); set({ error: "加载失败,请重试" });
} }
} finally { } finally {
set({ loading: false, loadingMore: false }); set({ loading: false, loadingMore: false });
@@ -126,26 +166,41 @@ const useHomeStore = create<HomeState>((set, get) => ({
}, },
refreshPlayRecords: async () => { 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 records = await PlayRecordManager.getAll();
const hasRecords = Object.keys(records).length > 0; const hasRecords = Object.keys(records).length > 0;
set(state => { set((state) => {
const recordCategoryExists = state.categories.some(c => c.type === 'record'); const recordCategoryExists = state.categories.some((c) => c.type === "record");
if (hasRecords && !recordCategoryExists) { if (hasRecords && !recordCategoryExists) {
return { categories: [initialCategories[0], ...state.categories] }; return { categories: [initialCategories[0], ...state.categories] };
} }
if (!hasRecords && recordCategoryExists) { if (!hasRecords && recordCategoryExists) {
const newCategories = state.categories.filter(c => c.type !== 'record'); const newCategories = state.categories.filter((c) => c.type !== "record");
if (state.selectedCategory.type === 'record') { if (state.selectedCategory.type === "record") {
get().selectCategory(newCategories[0] || null); get().selectCategory(newCategories[0] || null);
} }
return { categories: newCategories }; return { categories: newCategories };
} }
return {}; 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 Toast from "react-native-toast-message";
import { AVPlaybackStatus, Video } from "expo-av"; import { AVPlaybackStatus, Video } from "expo-av";
import { RefObject } from "react"; import { RefObject } from "react";
import { api, VideoDetail as ApiVideoDetail, SearchResult } from "@/services/api";
import { PlayRecord, PlayRecordManager } from "@/services/storage"; import { PlayRecord, PlayRecordManager } from "@/services/storage";
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
interface Episode { interface Episode {
url: string; url: string;
title: string; title: string;
} }
interface VideoDetail {
videoInfo: ApiVideoDetail["videoInfo"];
episodes: Episode[];
sources: SearchResult[];
}
interface PlayerState { interface PlayerState {
videoRef: RefObject<Video> | null; videoRef: RefObject<Video> | null;
detail: VideoDetail | null;
episodes: Episode[];
sources: SearchResult[];
currentSourceIndex: number;
currentEpisodeIndex: number; currentEpisodeIndex: number;
episodes: Episode[];
status: AVPlaybackStatus | null; status: AVPlaybackStatus | null;
isLoading: boolean; isLoading: boolean;
showControls: boolean; showControls: boolean;
@@ -36,8 +27,7 @@ interface PlayerState {
introEndTime?: number; introEndTime?: number;
outroStartTime?: number; outroStartTime?: number;
setVideoRef: (ref: RefObject<Video>) => void; setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>; loadVideo: (options: {source: string, id: string, title: string; episodeIndex: number, position?: number}) => Promise<void>;
switchSource: (newSourceIndex: number) => Promise<void>;
playEpisode: (index: number) => void; playEpisode: (index: number) => void;
togglePlayPause: () => void; togglePlayPause: () => void;
seek: (duration: number) => void; seek: (duration: number) => void;
@@ -57,11 +47,8 @@ interface PlayerState {
const usePlayerStore = create<PlayerState>((set, get) => ({ const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null, videoRef: null,
detail: null,
episodes: [], episodes: [],
sources: [], currentEpisodeIndex: -1,
currentSourceIndex: 0,
currentEpisodeIndex: 0,
status: null, status: null,
isLoading: true, isLoading: true,
showControls: false, showControls: false,
@@ -78,72 +65,45 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
setVideoRef: (ref) => set({ videoRef: ref }), 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({ set({
isLoading: true, 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 { try {
const videoDetail = await api.getVideoDetail(source, id); const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` })); const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
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);
set({ set({
detail: { videoInfo: videoDetail.videoInfo, episodes, sources },
episodes,
sources,
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
currentEpisodeIndex: episodeIndex,
isLoading: false, isLoading: false,
currentEpisodeIndex: episodeIndex,
initialPosition: position || initialPositionFromRecord,
episodes: episodes.map((ep, index) => ({
url: ep,
title: `${index + 1}`,
})),
introEndTime: playRecord?.introEndTime, introEndTime: playRecord?.introEndTime,
outroStartTime: playRecord?.outroStartTime, outroStartTime: playRecord?.outroStartTime,
}); });
} catch (error) { } catch (error) {
console.error("Failed to load video details", error); console.info("Failed to load play record", error);
set({ isLoading: false }); set({ isLoading: false });
} }
}, },
switchSource: async (newSourceIndex: number) => { playEpisode: async (index) => {
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) => {
const { episodes, videoRef } = get(); const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) { if (index >= 0 && index < episodes.length) {
set({ set({
@@ -153,27 +113,42 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
progressPosition: 0, progressPosition: 0,
seekPosition: 0, seekPosition: 0,
}); });
videoRef?.current?.replayAsync(); try {
} await videoRef?.current?.replayAsync();
}, } catch (error) {
console.error("Failed to replay video:", error);
togglePlayPause: () => { Toast.show({ type: "error", text1: "播放失败" });
const { status, videoRef } = get();
if (status?.isLoaded) {
if (status.isPlaying) {
videoRef?.current?.pauseAsync();
} else {
videoRef?.current?.playAsync();
} }
} }
}, },
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(); const { status, videoRef } = get();
if (!status?.isLoaded || !status.durationMillis) return; if (!status?.isLoaded || !status.durationMillis) return;
const newPosition = Math.max(0, Math.min(status.positionMillis + duration, status.durationMillis)); 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({ set({
isSeeking: true, isSeeking: true,
@@ -188,7 +163,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}, },
setIntroEndTime: () => { setIntroEndTime: () => {
const { status, detail, introEndTime: existingIntroEndTime } = get(); const { status, introEndTime: existingIntroEndTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return; if (!status?.isLoaded || !detail) return;
if (existingIntroEndTime) { if (existingIntroEndTime) {
@@ -213,7 +189,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}, },
setOutroStartTime: () => { setOutroStartTime: () => {
const { status, detail, outroStartTime: existingOutroStartTime } = get(); const { status, outroStartTime: existingOutroStartTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return; if (!status?.isLoaded || !detail) return;
if (existingOutroStartTime) { if (existingOutroStartTime) {
@@ -226,7 +203,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}); });
} else { } else {
// Set the time // Set the time
const newOutroStartTime = status.positionMillis; if (!status.durationMillis) return;
const newOutroStartTime = status.durationMillis - status.positionMillis;
set({ outroStartTime: newOutroStartTime }); set({ outroStartTime: newOutroStartTime });
get()._savePlayRecord({ outroStartTime: newOutroStartTime }); get()._savePlayRecord({ outroStartTime: newOutroStartTime });
Toast.show({ Toast.show({
@@ -238,21 +216,22 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}, },
_savePlayRecord: (updates = {}) => { _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) { if (detail && status?.isLoaded) {
const { videoInfo } = detail;
const existingRecord = { const existingRecord = {
introEndTime, introEndTime,
outroStartTime, outroStartTime,
}; };
PlayRecordManager.save(videoInfo.source, videoInfo.id, { PlayRecordManager.save(detail.source, detail.id.toString(), {
title: videoInfo.title, title: detail.title,
cover: videoInfo.cover || "", cover: detail.poster || "",
index: currentEpisodeIndex, index: currentEpisodeIndex + 1,
total_episodes: episodes.length, total_episodes: episodes.length,
play_time: status.positionMillis, play_time: Math.floor(status.positionMillis / 1000),
total_time: status.durationMillis || 0, total_time: status.durationMillis ? Math.floor(status.durationMillis / 1000) : 0,
source_name: videoInfo.source_name, source_name: detail.source_name,
year: detail.year || "",
...existingRecord, ...existingRecord,
...updates, ...updates,
}); });
@@ -262,15 +241,20 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
handlePlaybackStatusUpdate: (newStatus) => { handlePlaybackStatusUpdate: (newStatus) => {
if (!newStatus.isLoaded) { if (!newStatus.isLoaded) {
if (newStatus.error) { if (newStatus.error) {
console.error(`Playback Error: ${newStatus.error}`); console.info(`Playback Error: ${newStatus.error}`);
} }
set({ status: newStatus }); set({ status: newStatus });
return; 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) { if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1); playEpisode(currentEpisodeIndex + 1);
return; // Stop further processing for this update return; // Stop further processing for this update
@@ -306,10 +290,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
reset: () => { reset: () => {
set({ set({
detail: null,
episodes: [], episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0, currentEpisodeIndex: 0,
status: null, status: null,
isLoading: true, isLoading: true,
@@ -325,3 +306,9 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
})); }));
export default usePlayerStore; 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 }); set({ isServerRunning: true, serverUrl: url, error: null });
} catch { } catch {
const errorMessage = '启动失败,请强制退应用后重试。'; const errorMessage = '启动失败,请强制退应用后重试。';
console.error('[RemoteControlStore] Failed to start server:', errorMessage); console.info('[RemoteControlStore] Failed to start server:', errorMessage);
set({ error: errorMessage }); set({ error: errorMessage });
} }
}, },

View File

@@ -1,8 +1,7 @@
import { create } from 'zustand'; import { create } from "zustand";
import { SettingsManager } from '@/services/storage'; import { SettingsManager } from "@/services/storage";
import { api } from '@/services/api'; import { api, ServerConfig } from "@/services/api";
import useHomeStore from './homeStore'; import { storageConfig } from "@/services/storageConfig";
interface SettingsState { interface SettingsState {
apiBaseUrl: string; apiBaseUrl: string;
@@ -15,29 +14,32 @@ interface SettingsState {
}; };
}; };
isModalVisible: boolean; isModalVisible: boolean;
serverConfig: ServerConfig | null;
loadSettings: () => Promise<void>; loadSettings: () => Promise<void>;
fetchServerConfig: () => Promise<void>;
setApiBaseUrl: (url: string) => void; setApiBaseUrl: (url: string) => void;
setM3uUrl: (url: string) => void; setM3uUrl: (url: string) => void;
setRemoteInputEnabled: (enabled: boolean) => void; setRemoteInputEnabled: (enabled: boolean) => void;
saveSettings: () => Promise<void>; saveSettings: () => Promise<void>;
setVideoSource: (config: { enabledAll: boolean; sources: {[key: string]: boolean} }) => void; setVideoSource: (config: { enabledAll: boolean; sources: { [key: string]: boolean } }) => void;
showModal: () => void; showModal: () => void;
hideModal: () => void; hideModal: () => void;
} }
export const useSettingsStore = create<SettingsState>((set, get) => ({ export const useSettingsStore = create<SettingsState>((set, get) => ({
apiBaseUrl: '', apiBaseUrl: "",
m3uUrl: 'https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u', m3uUrl: "",
liveStreamSources: [], liveStreamSources: [],
remoteInputEnabled: false, remoteInputEnabled: false,
isModalVisible: false, isModalVisible: false,
serverConfig: null,
videoSource: { videoSource: {
enabledAll: true, enabledAll: true,
sources: {}, sources: {},
}, },
loadSettings: async () => { loadSettings: async () => {
const settings = await SettingsManager.get(); const settings = await SettingsManager.get();
set({ set({
apiBaseUrl: settings.apiBaseUrl, apiBaseUrl: settings.apiBaseUrl,
m3uUrl: settings.m3uUrl, m3uUrl: settings.m3uUrl,
remoteInputEnabled: settings.remoteInputEnabled || false, remoteInputEnabled: settings.remoteInputEnabled || false,
@@ -47,6 +49,18 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
}, },
}); });
api.setBaseUrl(settings.apiBaseUrl); 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 }), setApiBaseUrl: (url) => set({ apiBaseUrl: url }),
setM3uUrl: (url) => set({ m3uUrl: url }), setM3uUrl: (url) => set({ m3uUrl: url }),
@@ -54,7 +68,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setVideoSource: (config) => set({ videoSource: config }), setVideoSource: (config) => set({ videoSource: config }),
saveSettings: async () => { saveSettings: async () => {
const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get(); const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get();
await SettingsManager.save({ await SettingsManager.save({
apiBaseUrl, apiBaseUrl,
m3uUrl, m3uUrl,
remoteInputEnabled, remoteInputEnabled,
@@ -62,8 +76,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
}); });
api.setBaseUrl(apiBaseUrl); api.setBaseUrl(apiBaseUrl);
set({ isModalVisible: false }); set({ isModalVisible: false });
useHomeStore.getState().fetchInitialData(); await get().fetchServerConfig();
}, },
showModal: () => set({ isModalVisible: true }), showModal: () => set({ isModalVisible: true }),
hideModal: () => set({ isModalVisible: false }), 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" resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.4.1.tgz#a3c247aceab35f75dd0aa4bfa85d2be5a4508688"
integrity sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg== 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": "@react-native-tvos/config-tv@^0.0.10":
version "0.0.10" version "0.0.10"
resolved "https://registry.yarnpkg.com/@react-native-tvos/config-tv/-/config-tv-0.0.10.tgz#38fe1571e24c6790b43137d130832c68b366c295" resolved "https://registry.yarnpkg.com/@react-native-tvos/config-tv/-/config-tv-0.0.10.tgz#38fe1571e24c6790b43137d130832c68b366c295"