diff --git a/app/_layout.tsx b/app/_layout.tsx index ec43e1d..d97f022 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,6 +10,7 @@ import { useEffect } from "react"; import { Platform } from "react-native"; import { useColorScheme } from "@/hooks/useColorScheme"; +import { initializeApi } from "@/services/api"; // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); @@ -29,6 +30,10 @@ export default function RootLayout() { } }, [loaded, error]); + useEffect(() => { + initializeApi(); + }, []); + if (!loaded && !error) { return null; } diff --git a/app/index.tsx b/app/index.tsx index 061a12c..fdd9e92 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -30,7 +30,8 @@ import VideoCard from "@/components/VideoCard.tv"; import { PlayRecordManager } from "@/services/storage"; import { useFocusEffect, useRouter } from "expo-router"; import { useColorScheme } from "react-native"; -import { Search } from "lucide-react-native"; +import { Search, Settings } from "lucide-react-native"; +import { SettingsModal } from "@/components/SettingsModal"; // --- 类别定义 --- interface Category { @@ -68,6 +69,7 @@ export default function HomeScreen() { const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); + const [isSettingsVisible, setSettingsVisible] = useState(false); const [pageStart, setPageStart] = useState(0); const [hasMore, setHasMore] = useState(true); @@ -145,9 +147,13 @@ export default function HomeScreen() { setPageStart((prev) => prev + result.list.length); setHasMore(true); } - } catch (err) { + } catch (err: any) { console.error("Failed to load data:", err); - setError("加载失败,请重试"); + if (err.message === "API_URL_NOT_SET") { + setError("请点击右上角设置按钮,配置您的 API 地址"); + } else { + setError("加载失败,请重试"); + } } finally { setLoading(false); setLoadingMore(false); @@ -244,18 +250,32 @@ export default function HomeScreen() { {/* 顶部导航 */} 首页 - [ - styles.searchButton, - focused && styles.searchButtonFocused, - ]} - onPress={() => router.push({ pathname: "/search" })} - > - - + + [ + styles.searchButton, + focused && styles.searchButtonFocused, + ]} + onPress={() => router.push({ pathname: "/search" })} + > + + + [ + styles.searchButton, + focused && styles.searchButtonFocused, + ]} + onPress={() => setSettingsVisible(true)} + > + + + {/* 分类选择器 */} @@ -297,6 +317,14 @@ export default function HomeScreen() { } /> )} + setSettingsVisible(false)} + onSave={() => { + setSettingsVisible(false); + loadInitialData(); + }} + /> ); } @@ -324,9 +352,14 @@ const styles = StyleSheet.create({ fontWeight: "bold", paddingTop: 16, }, + rightHeaderButtons: { + flexDirection: "row", + alignItems: "center", + }, searchButton: { padding: 10, borderRadius: 30, + marginLeft: 10, }, searchButtonFocused: { backgroundColor: "#007AFF", diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx new file mode 100644 index 0000000..d0c5bfa --- /dev/null +++ b/components/SettingsModal.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect } from "react"; +import { + Modal, + View, + Text, + TextInput, + StyleSheet, + Pressable, + useColorScheme, +} from "react-native"; +import { SettingsManager } from "@/services/storage"; +import { moonTVApi } from "@/services/api"; +import { ThemedText } from "./ThemedText"; +import { ThemedView } from "./ThemedView"; + +interface SettingsModalProps { + visible: boolean; + onCancel: () => void; + onSave: () => void; +} + +export const SettingsModal: React.FC = ({ + visible, + onCancel, + onSave, +}) => { + const [apiUrl, setApiUrl] = useState(""); + const colorScheme = useColorScheme(); + + useEffect(() => { + if (visible) { + SettingsManager.get().then((settings) => { + setApiUrl(settings.apiBaseUrl); + }); + } + }, [visible]); + + const handleSave = async () => { + await SettingsManager.save({ apiBaseUrl: apiUrl }); + moonTVApi.setBaseUrl(apiUrl); + onSave(); + }; + + const styles = StyleSheet.create({ + modalContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, + modalContent: { + width: "80%", + maxWidth: 500, + padding: 24, + borderRadius: 12, + elevation: 10, + }, + title: { + fontSize: 24, + fontWeight: "bold", + marginBottom: 20, + textAlign: "center", + }, + input: { + height: 50, + borderWidth: 2, + borderRadius: 8, + paddingHorizontal: 15, + fontSize: 16, + marginBottom: 24, + backgroundColor: colorScheme === "dark" ? "#3a3a3c" : "#f0f0f0", + color: colorScheme === "dark" ? "white" : "black", + borderColor: "transparent", + }, + buttonContainer: { + flexDirection: "row", + justifyContent: "space-around", + }, + button: { + flex: 1, + paddingVertical: 12, + borderRadius: 8, + alignItems: "center", + marginHorizontal: 8, + }, + buttonSave: { + backgroundColor: "#007AFF", + }, + buttonCancel: { + backgroundColor: colorScheme === "dark" ? "#444" : "#ccc", + }, + buttonText: { + color: "white", + fontSize: 18, + fontWeight: "500", + }, + focusedButton: { + transform: [{ scale: 1.05 }], + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 5, + elevation: 8, + }, + }); + + return ( + + + + 设置 + + + [ + styles.button, + styles.buttonCancel, + focused && styles.focusedButton, + ]} + onPress={onCancel} + > + 取消 + + [ + styles.button, + styles.buttonSave, + focused && styles.focusedButton, + ]} + onPress={handleSave} + > + 保存 + + + + + + ); +}; diff --git a/services/api.ts b/services/api.ts index 5530fe3..0f2c7d2 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,3 +1,5 @@ +import { SettingsManager } from "./storage"; + export interface DoubanItem { title: string; poster: string; @@ -57,7 +59,17 @@ export interface PlayRecord { } export class API { - private baseURL: string = "https://orion-tv.vercel.app"; + public baseURL: string = ""; + + constructor(baseURL?: string) { + if (baseURL) { + this.baseURL = baseURL; + } + } + + public setBaseUrl(url: string) { + this.baseURL = url; + } /** * 生成图片代理 URL @@ -77,6 +89,9 @@ export class API { pageSize: number = 16, pageStart: number = 0 ): Promise { + if (!this.baseURL) { + throw new Error("API_URL_NOT_SET"); + } const url = `${ this.baseURL }/api/douban?type=${type}&tag=${encodeURIComponent( @@ -92,6 +107,9 @@ export class API { * 搜索视频 */ async searchVideos(query: string): Promise<{ results: SearchResult[] }> { + if (!this.baseURL) { + throw new Error("API_URL_NOT_SET"); + } const url = `${this.baseURL}/api/search?q=${encodeURIComponent(query)}`; const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); @@ -102,6 +120,9 @@ export class API { * 获取视频详情 */ async getVideoDetail(source: string, id: string): Promise { + if (!this.baseURL) { + throw new Error("API_URL_NOT_SET"); + } const url = `${this.baseURL}/api/detail?source=${source}&id=${id}`; const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); @@ -150,56 +171,10 @@ export class API { } // 默认实例 -export const moonTVApi = new API(); +export let moonTVApi = new API(); -// 生成模拟数据的辅助函数 -export const generateMockDoubanData = (count: number = 20): DoubanItem[] => { - const movieTitles = [ - "肖申克的救赎", - "霸王别姬", - "阿甘正传", - "泰坦尼克号", - "这个杀手不太冷", - "千与千寻", - "美丽人生", - "辛德勒的名单", - "星际穿越", - "盗梦空间", - "忠犬八公的故事", - "教父", - "龙猫", - "当幸福来敲门", - "三傻大闹宝莱坞", - "机器人总动员", - "放牛班的春天", - "无间道", - "楚门的世界", - "大话西游之大圣娶亲", - ]; - - return Array.from( - { length: Math.min(count, movieTitles.length) }, - (_, index) => ({ - title: movieTitles[index] || `影片 ${index + 1}`, - poster: `https://picsum.photos/160/240?random=${index}`, - rate: (Math.random() * 3 + 7).toFixed(1), - }) - ); -}; - -export const generateMockSearchResults = ( - query: string, - count: number = 20 -): SearchResult[] => { - return Array.from({ length: count }, (_, index) => ({ - id: index + 1, - title: `搜索结果:${query} ${index + 1}`, - poster: `https://picsum.photos/160/240?random=${index + 100}`, - episodes: [`第1集`, `第2集`, `第3集`], - source: "mock", - source_name: "模拟源", - year: (2020 + Math.floor(Math.random() * 4)).toString(), - desc: `这是关于 ${query} 的搜索结果 ${index + 1}`, - type_name: Math.random() > 0.5 ? "电影" : "电视剧", - })); +// 初始化 API +export const initializeApi = async () => { + const settings = await SettingsManager.get(); + moonTVApi.setBaseUrl(settings.apiBaseUrl); }; diff --git a/services/storage.ts b/services/storage.ts index 2f70e2a..3ad4b16 100644 --- a/services/storage.ts +++ b/services/storage.ts @@ -25,6 +25,7 @@ export interface AppSettings { theme: "light" | "dark" | "auto"; autoPlay: boolean; playbackSpeed: number; + apiBaseUrl: string; } // --- Helper --- @@ -177,6 +178,7 @@ export class SettingsManager { theme: "auto", autoPlay: true, playbackSpeed: 1.0, + apiBaseUrl: "http://127.0.0.1:3001", }; try { const data = await AsyncStorage.getItem(STORAGE_KEYS.SETTINGS);