diff --git a/.gitignore b/.gitignore index eadc82b..ed42053 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,10 @@ web-build/ # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb # The following patterns were generated by expo-cli - +.vscode expo-env.d.ts # @end expo-cli web/** .bmad-core -.kilocodemodes \ No newline at end of file +.kilocodemodes +.roomodes \ No newline at end of file diff --git a/app/detail.tsx b/app/detail.tsx index b4668d7..ed9915b 100644 --- a/app/detail.tsx +++ b/app/detail.tsx @@ -1,80 +1,114 @@ -import React, { useEffect, useState } from "react"; -import { - View, - Text, - StyleSheet, - Image, - ScrollView, - ActivityIndicator, -} from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { ThemedView } from "@/components/ThemedView"; -import { ThemedText } from "@/components/ThemedText"; -import { moonTVApi, SearchResult } from "@/services/api"; -import { getResolutionFromM3U8 } from "@/services/m3u8"; -import { DetailButton } from "@/components/DetailButton"; +import React, { useEffect, useState, useRef } from 'react'; +import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { ThemedView } from '@/components/ThemedView'; +import { ThemedText } from '@/components/ThemedText'; +import { api, SearchResult } from '@/services/api'; +import { getResolutionFromM3U8 } from '@/services/m3u8'; +import { DetailButton } from '@/components/DetailButton'; export default function DetailScreen() { const { source, q } = useLocalSearchParams(); const router = useRouter(); - const [searchResults, setSearchResults] = useState< - (SearchResult & { resolution?: string | null })[] - >([]); - const [detail, setDetail] = useState< - (SearchResult & { resolution?: string | null }) | null - >(null); + const [searchResults, setSearchResults] = useState<(SearchResult & { resolution?: string | null })[]>([]); + const [detail, setDetail] = useState<(SearchResult & { resolution?: string | null }) | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [allSourcesLoaded, setAllSourcesLoaded] = useState(false); + const controllerRef = useRef(null); useEffect(() => { - if (typeof source === "string" && typeof q === "string") { - const fetchDetailData = async () => { - try { - setLoading(true); - const { results } = await moonTVApi.searchVideos(q as string); - if (results && results.length > 0) { - const initialDetail = - results.find((r) => r.source === source) || results[0]; - setDetail(initialDetail); - setSearchResults(results); // Set initial results first + if (controllerRef.current) { + controllerRef.current.abort(); + } + controllerRef.current = new AbortController(); + const signal = controllerRef.current.signal; - // Asynchronously fetch resolutions - const resultsWithResolutions = await Promise.all( - results.map(async (searchResult) => { + if (typeof q === 'string') { + const fetchDetailData = async () => { + setLoading(true); + setSearchResults([]); + setDetail(null); + setError(null); + setAllSourcesLoaded(false); + + try { + const resources = await api.getResources(signal); + if (!resources || 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 - ) { - const resolution = await getResolutionFromM3U8( - searchResult.episodes[0] - ); - return { ...searchResult, resolution }; + if (searchResult.episodes && searchResult.episodes.length > 0) { + resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal); } } catch (e) { - console.error("Failed to get resolution for source", e); + if ((e as Error).name !== 'AbortError') { + console.error(`Failed to get resolution for ${resource.name}`, e); + } } - return searchResult; // Return original if fails - }) - ); - setSearchResults(resultsWithResolutions); - } else { - setError("未找到播放源"); + + 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) { - setError(e instanceof Error ? e.message : "获取详情失败"); + if ((e as Error).name !== 'AbortError') { + setError(e instanceof Error ? e.message : '获取资源列表失败'); + setLoading(false); + } } finally { - setLoading(false); + setAllSourcesLoaded(true); } }; fetchDetailData(); } - }, [source, q]); + + return () => { + controllerRef.current?.abort(); + }; + }, [q, source]); const handlePlay = (episodeName: string, episodeIndex: number) => { if (!detail) return; + controllerRef.current?.abort(); // Cancel any ongoing fetches router.push({ - pathname: "/play", + pathname: '/play', params: { source: detail.source, id: detail.id.toString(), @@ -121,9 +155,7 @@ export default function DetailScreen() { {detail.year} - - {detail.type_name} - + {detail.type_name} {detail.desc} @@ -133,37 +165,28 @@ export default function DetailScreen() { - - 选择播放源 共 {searchResults.length} 个 - + + 选择播放源 共 {searchResults.length} 个 + {!allSourcesLoaded && } + {searchResults.map((item, index) => ( setDetail(item)} hasTVPreferredFocus={index === 0} - style={[ - styles.sourceButton, - detail?.source === item.source && - styles.sourceButtonSelected, - ]} + style={[styles.sourceButton, detail?.source === item.source && styles.sourceButtonSelected]} > - - {item.source_name} - + {item.source_name} {item.episodes.length > 1 && ( - {item.episodes.length > 99 - ? "99+" - : `${item.episodes.length}`} + {item.episodes.length > 99 ? '99+' : `${item.episodes.length}`} )} {item.resolution && ( - + {item.resolution} )} @@ -175,14 +198,8 @@ export default function DetailScreen() { 播放列表 {detail.episodes.map((episode, index) => ( - handlePlay(episode, index)} - > - {`第 ${ - index + 1 - } 集`} + handlePlay(episode, index)}> + {`第 ${index + 1} 集`} ))} @@ -195,9 +212,9 @@ export default function DetailScreen() { const styles = StyleSheet.create({ container: { flex: 1 }, - centered: { flex: 1, justifyContent: "center", alignItems: "center" }, + centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, topContainer: { - flexDirection: "row", + flexDirection: 'row', padding: 20, }, poster: { @@ -208,20 +225,20 @@ const styles = StyleSheet.create({ infoContainer: { flex: 1, marginLeft: 20, - justifyContent: "flex-start", + justifyContent: 'flex-start', }, title: { fontSize: 28, - fontWeight: "bold", + fontWeight: 'bold', marginBottom: 10, paddingTop: 20, }, metaContainer: { - flexDirection: "row", + flexDirection: 'row', marginBottom: 10, }, metaText: { - color: "#aaa", + color: '#aaa', marginRight: 10, fontSize: 14, }, @@ -230,7 +247,7 @@ const styles = StyleSheet.create({ }, description: { fontSize: 14, - color: "#ccc", + color: '#ccc', lineHeight: 22, }, bottomContainer: { @@ -239,67 +256,71 @@ const styles = StyleSheet.create({ sourcesContainer: { marginTop: 20, }, - sourcesTitle: { - fontSize: 20, - fontWeight: "bold", + sourcesTitleContainer: { + flexDirection: 'row', + alignItems: 'center', marginBottom: 10, }, + sourcesTitle: { + fontSize: 20, + fontWeight: 'bold', + }, sourceList: { - flexDirection: "row", - flexWrap: "wrap", + flexDirection: 'row', + flexWrap: 'wrap', }, sourceButton: { - backgroundColor: "#333", + backgroundColor: '#333', paddingHorizontal: 15, paddingVertical: 10, borderRadius: 8, margin: 5, - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', borderWidth: 2, - borderColor: "transparent", + borderColor: 'transparent', }, sourceButtonSelected: { - backgroundColor: "#007bff", + backgroundColor: '#007bff', }, sourceButtonText: { - color: "white", + color: 'white', fontSize: 16, }, badge: { - backgroundColor: "red", + backgroundColor: 'red', borderRadius: 10, paddingHorizontal: 6, paddingVertical: 2, marginLeft: 8, }, badgeText: { - color: "white", + color: 'white', fontSize: 12, - fontWeight: "bold", + fontWeight: 'bold', }, episodesContainer: { marginTop: 20, }, episodesTitle: { fontSize: 20, - fontWeight: "bold", + fontWeight: 'bold', marginBottom: 10, }, episodeList: { - flexDirection: "row", - flexWrap: "wrap", + flexDirection: 'row', + flexWrap: 'wrap', }, episodeButton: { - backgroundColor: "#333", + backgroundColor: '#333', paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8, margin: 5, borderWidth: 2, - borderColor: "transparent", + borderColor: 'transparent', }, episodeButtonText: { - color: "white", + color: 'white', }, }); diff --git a/app/index.tsx b/app/index.tsx index 2f7346d..f0d64fa 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,17 +1,10 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; -import { - View, - StyleSheet, - ActivityIndicator, - FlatList, - Pressable, - Dimensions, -} from "react-native"; -import { ThemedView } from "@/components/ThemedView"; -import { ThemedText } from "@/components/ThemedText"; -import { moonTVApi } from "@/services/api"; -import { SearchResult } from "@/services/api"; -import { PlayRecord } from "@/services/storage"; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions } from 'react-native'; +import { ThemedView } from '@/components/ThemedView'; +import { ThemedText } from '@/components/ThemedText'; +import { api } from '@/services/api'; +import { SearchResult } from '@/services/api'; +import { PlayRecord } from '@/services/storage'; export type RowItem = (SearchResult | PlayRecord) & { id: string; @@ -26,35 +19,35 @@ export type RowItem = (SearchResult | PlayRecord) & { year?: string; rate?: string; }; -import VideoCard from "@/components/VideoCard.tv"; -import { PlayRecordManager } from "@/services/storage"; -import { useFocusEffect, useRouter } from "expo-router"; -import { useColorScheme } from "react-native"; -import { Search, Settings } from "lucide-react-native"; -import { SettingsModal } from "@/components/SettingsModal"; +import VideoCard from '@/components/VideoCard.tv'; +import { PlayRecordManager } from '@/services/storage'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useColorScheme } from 'react-native'; +import { Search, Settings } from 'lucide-react-native'; +import { SettingsModal } from '@/components/SettingsModal'; // --- 类别定义 --- interface Category { title: string; - type?: "movie" | "tv" | "record"; + type?: 'movie' | 'tv' | 'record'; tag?: string; } const initialCategories: Category[] = [ - { title: "最近播放", type: "record" }, - { title: "综艺", type: "tv", tag: "综艺" }, - { title: "热门剧集", type: "tv", tag: "热门" }, - { title: "热门电影", type: "movie", tag: "热门" }, - { title: "豆瓣 Top250", type: "movie", tag: "top250" }, - { title: "儿童", type: "movie", tag: "少儿" }, - { title: "美剧", type: "tv", tag: "美剧" }, - { title: "韩剧", type: "tv", tag: "韩剧" }, - { title: "日剧", type: "tv", tag: "日剧" }, - { title: "日漫", type: "tv", tag: "日本动画" }, + { title: '最近播放', type: 'record' }, + { title: '热门剧集', type: 'tv', tag: '热门' }, + { title: '综艺', type: 'tv', tag: '综艺' }, + { title: '热门电影', type: 'movie', tag: '热门' }, + { title: '豆瓣 Top250', type: 'movie', tag: 'top250' }, + { title: '儿童', type: 'movie', tag: '少儿' }, + { title: '美剧', type: 'tv', tag: '美剧' }, + { title: '韩剧', type: 'tv', tag: '韩剧' }, + { title: '日剧', type: 'tv', tag: '日剧' }, + { title: '日漫', type: 'tv', tag: '日本动画' }, ]; const NUM_COLUMNS = 5; -const { width } = Dimensions.get("window"); +const { width } = Dimensions.get('window'); const ITEM_WIDTH = width / NUM_COLUMNS - 24; export default function HomeScreen() { @@ -62,9 +55,7 @@ export default function HomeScreen() { const colorScheme = useColorScheme(); const [categories, setCategories] = useState(initialCategories); - const [selectedCategory, setSelectedCategory] = useState( - categories[0] - ); + const [selectedCategory, setSelectedCategory] = useState(categories[0]); const [contentData, setContentData] = useState([]); const [loading, setLoading] = useState(true); @@ -82,7 +73,7 @@ export default function HomeScreen() { const records = await PlayRecordManager.getAll(); return Object.entries(records) .map(([key, record]) => { - const [source, id] = key.split("+"); + const [source, id] = key.split('+'); return { id, source, @@ -95,23 +86,20 @@ export default function HomeScreen() { totalEpisodes: record.total_episodes, } as RowItem; }) - .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)); }; - const fetchData = async (category: Category, start: number) => { - if (category.type === "record") { - const records = await fetchPlayRecords(); - if (records.length === 0 && categories[0].type === "record") { + const fetchData = async (category: Category, start: number, preloadedRecords?: RowItem[]) => { + if (category.type === 'record') { + const records = preloadedRecords ?? (await fetchPlayRecords()); + if (records.length === 0 && categories.some(c => c.type === 'record')) { // 如果没有播放记录,则移除"最近播放"分类并选择第一个真实分类 - const newCategories = categories.slice(1); + const newCategories = categories.filter(c => c.type !== 'record'); setCategories(newCategories); - handleCategorySelect(newCategories[0]); + if (newCategories.length > 0) { + handleCategorySelect(newCategories[0]); + } } else { setContentData(records); setHasMore(false); @@ -126,33 +114,26 @@ export default function HomeScreen() { setError(null); try { - const result = await moonTVApi.getDoubanData( - category.type, - category.tag, - 20, - start - ); + const result = await api.getDoubanData(category.type, category.tag, 20, start); if (result.list.length === 0) { setHasMore(false); } else { - const newItems = result.list.map((item) => ({ + const newItems = result.list.map(item => ({ ...item, id: item.title, // 临时ID - source: "douban", + source: 'douban', })) as RowItem[]; - setContentData((prev) => - start === 0 ? newItems : [...prev, ...newItems] - ); - setPageStart((prev) => prev + result.list.length); + setContentData(prev => (start === 0 ? newItems : [...prev, ...newItems])); + setPageStart(prev => prev + result.list.length); setHasMore(true); } } catch (err: any) { - if (err.message === "API_URL_NOT_SET") { - setError("请点击右上角设置按钮,配置您的 API 地址"); + if (err.message === 'API_URL_NOT_SET') { + setError('请点击右上角设置按钮,配置您的 API 地址'); } else { - setError("加载失败,请重试"); + setError('加载失败,请重试'); } } finally { setLoading(false); @@ -163,9 +144,27 @@ export default function HomeScreen() { // --- Effects --- useFocusEffect( useCallback(() => { - if (selectedCategory.type === "record") { - loadInitialData(); - } + const manageRecordCategory = async () => { + const records = await fetchPlayRecords(); + const hasRecords = records.length > 0; + + setCategories(currentCategories => { + const recordCategoryExists = currentCategories.some(c => c.type === 'record'); + if (hasRecords && !recordCategoryExists) { + // Add 'Recent Plays' if records exist and the tab doesn't + return [initialCategories[0], ...currentCategories]; + } + return currentCategories; + }); + + // If 'Recent Plays' is selected, always refresh its data. + // This will also handle removing the tab if records have disappeared. + if (selectedCategory.type === 'record') { + loadInitialData(records); + } + }; + + manageRecordCategory(); }, [selectedCategory]) ); @@ -173,23 +172,17 @@ export default function HomeScreen() { loadInitialData(); }, [selectedCategory]); - const loadInitialData = () => { + const loadInitialData = (records?: RowItem[]) => { setLoading(true); setContentData([]); setPageStart(0); setHasMore(true); flatListRef.current?.scrollToOffset({ animated: false, offset: 0 }); - fetchData(selectedCategory, 0); + fetchData(selectedCategory, 0, records); }; const loadMoreData = () => { - if ( - loading || - loadingMore || - !hasMore || - selectedCategory.type === "record" - ) - return; + if (loading || loadingMore || !hasMore || selectedCategory.type === 'record') return; fetchData(selectedCategory, pageStart); }; @@ -209,14 +202,7 @@ export default function HomeScreen() { ]} onPress={() => handleCategorySelect(item)} > - - {item.title} - + {item.title} ); }; @@ -234,7 +220,7 @@ export default function HomeScreen() { episodeIndex={item.episodeIndex} sourceName={item.sourceName} totalEpisodes={item.totalEpisodes} - api={moonTVApi} + api={api} onRecordDeleted={loadInitialData} // For "Recent Plays" /> @@ -252,28 +238,16 @@ export default function HomeScreen() { 首页 [ - styles.searchButton, - focused && styles.searchButtonFocused, - ]} - onPress={() => router.push({ pathname: "/search" })} + style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]} + onPress={() => router.push({ pathname: '/search' })} > - + [ - styles.searchButton, - focused && styles.searchButtonFocused, - ]} + style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]} onPress={() => setSettingsVisible(true)} > - + @@ -283,7 +257,7 @@ export default function HomeScreen() { item.title} + keyExtractor={item => item.title} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.categoryListContent} @@ -338,25 +312,26 @@ const styles = StyleSheet.create({ }, centerContainer: { flex: 1, - justifyContent: "center", - alignItems: "center", + paddingTop: 20, + justifyContent: 'center', + alignItems: 'center', }, // Header headerContainer: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', paddingHorizontal: 24, marginBottom: 10, }, headerTitle: { fontSize: 32, - fontWeight: "bold", + fontWeight: 'bold', paddingTop: 16, }, rightHeaderButtons: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', }, searchButton: { padding: 10, @@ -364,7 +339,7 @@ const styles = StyleSheet.create({ marginLeft: 10, }, searchButtonFocused: { - backgroundColor: "#007AFF", + backgroundColor: '#007AFF', transform: [{ scale: 1.1 }], }, // Category Selector @@ -381,18 +356,18 @@ const styles = StyleSheet.create({ marginHorizontal: 5, }, categoryButtonSelected: { - backgroundColor: "#007AFF", // A bright blue for selected state + backgroundColor: '#007AFF', // A bright blue for selected state }, categoryButtonFocused: { - backgroundColor: "#0056b3", // A darker blue for focused state + backgroundColor: '#0056b3', // A darker blue for focused state elevation: 5, }, categoryText: { fontSize: 16, - fontWeight: "500", + fontWeight: '500', }, categoryTextSelected: { - color: "#FFFFFF", + color: '#FFFFFF', }, // Content Grid listContent: { @@ -402,6 +377,6 @@ const styles = StyleSheet.create({ itemContainer: { margin: 8, width: ITEM_WIDTH, - alignItems: "center", + alignItems: 'center', }, }); diff --git a/app/search.tsx b/app/search.tsx index 2a8f6ab..bf0528c 100644 --- a/app/search.tsx +++ b/app/search.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect } from 'react'; import { View, TextInput, @@ -9,15 +9,15 @@ import { Text, Keyboard, useColorScheme, -} from "react-native"; -import { ThemedView } from "@/components/ThemedView"; -import { ThemedText } from "@/components/ThemedText"; -import VideoCard from "@/components/VideoCard.tv"; -import { moonTVApi, SearchResult } from "@/services/api"; -import { Search } from "lucide-react-native"; +} from 'react-native'; +import { ThemedView } from '@/components/ThemedView'; +import { ThemedText } from '@/components/ThemedText'; +import VideoCard from '@/components/VideoCard.tv'; +import { api, SearchResult } from '@/services/api'; +import { Search } from 'lucide-react-native'; export default function SearchScreen() { - const [keyword, setKeyword] = useState(""); + const [keyword, setKeyword] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -42,15 +42,15 @@ export default function SearchScreen() { setLoading(true); setError(null); try { - const response = await moonTVApi.searchVideos(keyword); + const response = await api.searchVideos(keyword); if (response.results.length > 0) { setResults(response.results); } else { - setError("没有找到相关内容"); + setError('没有找到相关内容'); } } catch (err) { - setError("搜索失败,请稍后重试。"); - console.error("Search failed:", err); + setError('搜索失败,请稍后重试。'); + console.error('Search failed:', err); } finally { setLoading(false); } @@ -64,7 +64,7 @@ export default function SearchScreen() { poster={item.poster} year={item.year} sourceName={item.source_name} - api={moonTVApi} + api={api} /> ); @@ -76,13 +76,13 @@ export default function SearchScreen() { style={[ styles.input, { - backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0", - color: colorScheme === "dark" ? "white" : "black", - borderColor: isInputFocused ? "#007bff" : "transparent", + backgroundColor: colorScheme === 'dark' ? '#2c2c2e' : '#f0f0f0', + color: colorScheme === 'dark' ? 'white' : 'black', + borderColor: isInputFocused ? '#007bff' : 'transparent', }, ]} placeholder="搜索电影、剧集..." - placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"} + placeholderTextColor={colorScheme === 'dark' ? '#888' : '#555'} value={keyword} onChangeText={setKeyword} onFocus={() => setIsInputFocused(true)} @@ -94,16 +94,13 @@ export default function SearchScreen() { style={({ focused }) => [ styles.searchButton, { - backgroundColor: colorScheme === "dark" ? "#3a3a3c" : "#e0e0e0", + backgroundColor: colorScheme === 'dark' ? '#3a3a3c' : '#e0e0e0', }, focused && styles.focusedButton, ]} onPress={handleSearch} > - + @@ -139,22 +136,22 @@ const styles = StyleSheet.create({ paddingTop: 50, }, searchContainer: { - flexDirection: "row", + flexDirection: 'row', paddingHorizontal: 20, marginBottom: 20, - alignItems: "center", + alignItems: 'center', }, input: { flex: 1, height: 50, - backgroundColor: "#2c2c2e", // Default for dark mode, overridden inline + backgroundColor: '#2c2c2e', // Default for dark mode, overridden inline borderRadius: 8, paddingHorizontal: 15, - color: "white", // Default for dark mode, overridden inline + color: 'white', // Default for dark mode, overridden inline fontSize: 18, marginRight: 10, borderWidth: 2, - borderColor: "transparent", // Default, overridden for focus + borderColor: 'transparent', // Default, overridden for focus }, searchButton: { padding: 12, @@ -162,16 +159,16 @@ const styles = StyleSheet.create({ borderRadius: 8, }, focusedButton: { - backgroundColor: "#007bff", + backgroundColor: '#007bff', transform: [{ scale: 1.1 }], }, centerContainer: { flex: 1, - justifyContent: "center", - alignItems: "center", + justifyContent: 'center', + alignItems: 'center', }, errorText: { - color: "red", + color: 'red', }, listContent: { paddingHorizontal: 10, diff --git a/backend/Dockerfile b/backend/Dockerfile index ed483ce..80d1446 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,7 +28,7 @@ COPY --from=builder /app/dist ./dist # Copy config.json from the project root relative to the Docker build context # IMPORTANT: When building, run `docker build -f backend/Dockerfile .` from the project root. -COPY config.json ./ +COPY src/config/config.json dist/config/ # Expose the port the app runs on EXPOSE 3001 diff --git a/backend/src/config/config.json b/backend/src/config/config.json index 617980c..6afc400 100644 --- a/backend/src/config/config.json +++ b/backend/src/config/config.json @@ -10,6 +10,10 @@ "api": "https://cj.rycjapi.com/api.php/provide/vod", "name": "如意资源" }, + "mozhua": { + "api": "https://mozhuazy.com/api.php/provide/vod", + "name": "魔爪资源" + }, "heimuer": { "api": "https://json.heimuer.xyz/api.php/provide/vod", "name": "黑木耳", @@ -53,10 +57,6 @@ "api": "https://dbzy.tv/api.php/provide/vod", "name": "豆瓣资源" }, - "mozhua": { - "api": "https://mozhuazy.com/api.php/provide/vod", - "name": "魔爪资源" - }, "mdzy": { "api": "https://www.mdzyapi.com/api.php/provide/vod", "name": "魔都资源" diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts index 94855a9..ec47b87 100644 --- a/backend/src/routes/search.ts +++ b/backend/src/routes/search.ts @@ -228,4 +228,43 @@ router.get("/", async (req: Request, res: Response) => { } }); +// 按资源 url 单个获取数据 +router.get("/one", async (req: Request, res: Response) => { + const { resourceId, q } = req.query; + + if (!resourceId || !q) { + return res.status(400).json({ error: "resourceId and q are required" }); + } + + const apiSites = getApiSites(); + const apiSite = apiSites.find((site) => site.key === (resourceId as string)); + + if (!apiSite) { + return res.status(404).json({ error: "Resource not found" }); + } + + try { + const results = await searchFromApi(apiSite, q as string); + const result = results.filter((r) => r.title === (q as string)); + + if (results) { + const cacheTime = getCacheTime(); + res.setHeader("Cache-Control", `public, max-age=${cacheTime}`); + res.json({results: result}); + } else { + res.status(404).json({ error: "Resource not found with the given query" }); + } + } catch (error) { + res.status(500).json({ error: "Failed to fetch resource details" }); + } +}); + +// 获取所有可用的资源列表 +router.get("/resources", async (req: Request, res: Response) => { + const apiSites = getApiSites(); + const cacheTime = getCacheTime(); + res.setHeader("Cache-Control", `public, max-age=${cacheTime}`); + res.json(apiSites); +}); + export default router; diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 8bb1c6c..7b8c3a5 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -1,17 +1,9 @@ -import React, { useState, useEffect, useRef } 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"; +import React, { useState, useEffect, useRef } from 'react'; +import { Modal, View, Text, TextInput, StyleSheet, Pressable, useColorScheme } from 'react-native'; +import { SettingsManager } from '@/services/storage'; +import { api } from '@/services/api'; +import { ThemedText } from './ThemedText'; +import { ThemedView } from './ThemedView'; interface SettingsModalProps { visible: boolean; @@ -19,19 +11,15 @@ interface SettingsModalProps { onSave: () => void; } -export const SettingsModal: React.FC = ({ - visible, - onCancel, - onSave, -}) => { - const [apiUrl, setApiUrl] = useState(""); +export const SettingsModal: React.FC = ({ visible, onCancel, onSave }) => { + const [apiUrl, setApiUrl] = useState(''); const [isInputFocused, setIsInputFocused] = useState(false); const colorScheme = useColorScheme(); const inputRef = useRef(null); useEffect(() => { if (visible) { - SettingsManager.get().then((settings) => { + SettingsManager.get().then(settings => { setApiUrl(settings.apiBaseUrl); }); const timer = setTimeout(() => { @@ -43,19 +31,19 @@ export const SettingsModal: React.FC = ({ const handleSave = async () => { await SettingsManager.save({ apiBaseUrl: apiUrl }); - moonTVApi.setBaseUrl(apiUrl); + api.setBaseUrl(apiUrl); onSave(); }; const styles = StyleSheet.create({ modalContainer: { flex: 1, - justifyContent: "center", - alignItems: "center", - backgroundColor: "rgba(0, 0, 0, 0.6)", + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.6)', }, modalContent: { - width: "80%", + width: '80%', maxWidth: 500, padding: 24, borderRadius: 12, @@ -63,9 +51,9 @@ export const SettingsModal: React.FC = ({ }, title: { fontSize: 24, - fontWeight: "bold", + fontWeight: 'bold', marginBottom: 20, - textAlign: "center", + textAlign: 'center', }, input: { height: 50, @@ -74,43 +62,43 @@ export const SettingsModal: React.FC = ({ paddingHorizontal: 15, fontSize: 16, marginBottom: 24, - backgroundColor: colorScheme === "dark" ? "#3a3a3c" : "#f0f0f0", - color: colorScheme === "dark" ? "white" : "black", - borderColor: "transparent", + backgroundColor: colorScheme === 'dark' ? '#3a3a3c' : '#f0f0f0', + color: colorScheme === 'dark' ? 'white' : 'black', + borderColor: 'transparent', }, inputFocused: { - borderColor: "#007AFF", - shadowColor: "#007AFF", + borderColor: '#007AFF', + shadowColor: '#007AFF', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 10, elevation: 5, }, buttonContainer: { - flexDirection: "row", - justifyContent: "space-around", + flexDirection: 'row', + justifyContent: 'space-around', }, button: { flex: 1, paddingVertical: 12, borderRadius: 8, - alignItems: "center", + alignItems: 'center', marginHorizontal: 8, }, buttonSave: { - backgroundColor: "#007AFF", + backgroundColor: '#007AFF', }, buttonCancel: { - backgroundColor: colorScheme === "dark" ? "#444" : "#ccc", + backgroundColor: colorScheme === 'dark' ? '#444' : '#ccc', }, buttonText: { - color: "white", + color: 'white', fontSize: 18, - fontWeight: "500", + fontWeight: '500', }, focusedButton: { transform: [{ scale: 1.05 }], - shadowColor: "#000", + shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 5, @@ -119,12 +107,7 @@ export const SettingsModal: React.FC = ({ }); return ( - + 设置 @@ -134,7 +117,7 @@ export const SettingsModal: React.FC = ({ value={apiUrl} onChangeText={setApiUrl} placeholder="输入 API 地址" - placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"} + placeholderTextColor={colorScheme === 'dark' ? '#888' : '#555'} autoCapitalize="none" autoCorrect={false} onFocus={() => setIsInputFocused(true)} @@ -142,21 +125,13 @@ export const SettingsModal: React.FC = ({ /> [ - styles.button, - styles.buttonCancel, - focused && styles.focusedButton, - ]} + style={({ focused }) => [styles.button, styles.buttonCancel, focused && styles.focusedButton]} onPress={onCancel} > 取消 [ - styles.button, - styles.buttonSave, - focused && styles.focusedButton, - ]} + style={({ focused }) => [styles.button, styles.buttonSave, focused && styles.focusedButton]} onPress={handleSave} > 保存 diff --git a/components/VideoCard.tv.tsx b/components/VideoCard.tv.tsx index 7040c01..b7faa49 100644 --- a/components/VideoCard.tv.tsx +++ b/components/VideoCard.tv.tsx @@ -1,23 +1,11 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; -import { - View, - Text, - Image, - StyleSheet, - Pressable, - TouchableOpacity, - Alert, -} from "react-native"; -import Animated, { - useSharedValue, - useAnimatedStyle, - withSpring, -} from "react-native-reanimated"; -import { useRouter } from "expo-router"; -import { Heart, Star, Play, Trash2 } from "lucide-react-native"; -import { FavoriteManager, PlayRecordManager } from "@/services/storage"; -import { API, moonTVApi } from "@/services/api"; -import { ThemedText } from "@/components/ThemedText"; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { View, Text, Image, StyleSheet, Pressable, TouchableOpacity, Alert } from 'react-native'; +import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; +import { useRouter } from 'expo-router'; +import { Heart, Star, Play, Trash2 } from 'lucide-react-native'; +import { FavoriteManager, PlayRecordManager } from '@/services/storage'; +import { API, api } from '@/services/api'; +import { ThemedText } from '@/components/ThemedText'; interface VideoCardProps { id: string; @@ -71,12 +59,12 @@ export default function VideoCard({ // 如果有播放进度,直接转到播放页面 if (progress !== undefined && episodeIndex !== undefined) { router.push({ - pathname: "/play", + pathname: '/play', params: { source, id, episodeIndex }, }); } else { router.push({ - pathname: "/detail", + pathname: '/detail', params: { source, q: title }, }); } @@ -100,14 +88,14 @@ export default function VideoCard({ longPressTriggered.current = true; // Show confirmation dialog to delete play record - Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [ + Alert.alert('删除观看记录', `确定要删除"${title}"的观看记录吗?`, [ { - text: "取消", - style: "cancel", + text: '取消', + style: 'cancel', }, { - text: "删除", - style: "destructive", + text: '删除', + style: 'destructive', onPress: async () => { try { // Delete from local storage @@ -119,11 +107,11 @@ export default function VideoCard({ } // 如果没有回调函数,则使用导航刷新作为备选方案 else if (router.canGoBack()) { - router.replace("/"); + router.replace('/'); } } catch (error) { - console.error("Failed to delete play record:", error); - Alert.alert("错误", "删除观看记录失败,请重试"); + console.error('Failed to delete play record:', error); + Alert.alert('错误', '删除观看记录失败,请重试'); } }, }, @@ -131,8 +119,7 @@ export default function VideoCard({ }; // 是否是继续观看的视频 - const isContinueWatching = - progress !== undefined && progress > 0 && progress < 1; + const isContinueWatching = progress !== undefined && progress > 0 && progress < 1; return ( @@ -146,18 +133,13 @@ export default function VideoCard({ delayLongPress={1000} > - + {isFocused && ( {isContinueWatching && ( - - 继续观看 - + 继续观看 )} @@ -166,12 +148,7 @@ export default function VideoCard({ {/* 进度条 */} {isContinueWatching && ( - + )} @@ -197,8 +174,7 @@ export default function VideoCard({ {isContinueWatching && !isFocused && ( - 第{episodeIndex! + 1}集 已观看{" "} - {Math.round((progress || 0) * 100)}% + 第{episodeIndex! + 1}集 已观看 {Math.round((progress || 0) * 100)}% )} @@ -216,126 +192,126 @@ const styles = StyleSheet.create({ marginHorizontal: 8, }, pressable: { - alignItems: "center", + alignItems: 'center', }, card: { width: CARD_WIDTH, height: CARD_HEIGHT, borderRadius: 8, - backgroundColor: "#222", - overflow: "hidden", + backgroundColor: '#222', + overflow: 'hidden', }, poster: { - width: "100%", - height: "100%", + width: '100%', + height: '100%', }, overlay: { ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0,0,0,0.3)", - justifyContent: "center", - alignItems: "center", + backgroundColor: 'rgba(0,0,0,0.3)', + justifyContent: 'center', + alignItems: 'center', }, buttonRow: { - position: "absolute", + position: 'absolute', top: 8, left: 8, - flexDirection: "row", + flexDirection: 'row', gap: 8, }, iconButton: { padding: 4, }, favButton: { - position: "absolute", + position: 'absolute', top: 8, left: 8, }, ratingContainer: { - position: "absolute", + position: 'absolute', top: 8, right: 8, - flexDirection: "row", - alignItems: "center", - backgroundColor: "rgba(0, 0, 0, 0.7)", + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.7)', borderRadius: 6, paddingHorizontal: 6, paddingVertical: 3, }, ratingText: { - color: "#FFD700", + color: '#FFD700', fontSize: 12, - fontWeight: "bold", + fontWeight: 'bold', marginLeft: 4, }, infoContainer: { width: CARD_WIDTH, marginTop: 8, - alignItems: "flex-start", // Align items to the start + alignItems: 'flex-start', // Align items to the start marginBottom: 16, paddingHorizontal: 4, // Add some padding }, infoRow: { - flexDirection: "row", - justifyContent: "space-between", - width: "100%", + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', }, title: { - color: "white", + color: 'white', fontSize: 16, - fontWeight: "bold", - textAlign: "center", + fontWeight: 'bold', + textAlign: 'center', }, yearBadge: { - position: "absolute", + position: 'absolute', top: 8, right: 8, - backgroundColor: "rgba(0, 0, 0, 0.7)", + backgroundColor: 'rgba(0, 0, 0, 0.7)', borderRadius: 6, paddingHorizontal: 6, paddingVertical: 3, }, sourceNameBadge: { - position: "absolute", + position: 'absolute', top: 8, left: 8, - backgroundColor: "rgba(0, 0, 0, 0.7)", + backgroundColor: 'rgba(0, 0, 0, 0.7)', borderRadius: 6, paddingHorizontal: 6, paddingVertical: 3, }, badgeText: { - color: "white", + color: 'white', fontSize: 12, - fontWeight: "bold", + fontWeight: 'bold', }, progressContainer: { - position: "absolute", + position: 'absolute', bottom: 0, left: 0, right: 0, height: 3, - backgroundColor: "rgba(0, 0, 0, 0.5)", + backgroundColor: 'rgba(0, 0, 0, 0.5)', }, progressBar: { height: 3, - backgroundColor: "#ff0000", + backgroundColor: '#ff0000', }, continueWatchingBadge: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "rgba(255, 0, 0, 0.8)", + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(255, 0, 0, 0.8)', paddingHorizontal: 10, paddingVertical: 5, borderRadius: 5, }, continueWatchingText: { - color: "white", + color: 'white', marginLeft: 5, fontSize: 12, - fontWeight: "bold", + fontWeight: 'bold', }, continueLabel: { - color: "#ff5252", + color: '#ff5252', fontSize: 12, }, }); diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index 98e1dd6..1b2cfba 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { useLocalSearchParams } from "expo-router"; import { Video, AVPlaybackStatus } from "expo-av"; -import { moonTVApi, VideoDetail } from "@/services/api"; +import { api, VideoDetail } from "@/services/api"; import { PlayRecordManager } from "@/services/storage"; import { getResolutionFromM3U8 } from "@/services/m3u8"; @@ -67,7 +67,7 @@ export const usePlaybackManager = (videoRef: React.RefObject