diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..52f01a6
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "tabWidth": 2,
+ "useTabs": false,
+ "singleQuote": false,
+ "printWidth": 120
+}
diff --git a/app/+not-found.tsx b/app/+not-found.tsx
index d057dbc..af77bca 100644
--- a/app/+not-found.tsx
+++ b/app/+not-found.tsx
@@ -1,13 +1,14 @@
-import {Link, Stack} from 'expo-router';
-import {StyleSheet} from 'react-native';
+import { Link, Stack } from "expo-router";
+import { StyleSheet } from "react-native";
-import {ThemedText} from '@/components/ThemedText';
-import {ThemedView} from '@/components/ThemedView';
+import { ThemedText } from "@/components/ThemedText";
+import { ThemedView } from "@/components/ThemedView";
+import React from "react";
export default function NotFoundScreen() {
return (
<>
-
+
This screen doesn't exist.
@@ -21,8 +22,8 @@ export default function NotFoundScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
+ alignItems: "center",
+ justifyContent: "center",
padding: 20,
},
link: {
diff --git a/app/_layout.tsx b/app/_layout.tsx
index d97f022..700de07 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,25 +1,26 @@
-import {
- DarkTheme,
- DefaultTheme,
- ThemeProvider,
-} from "@react-navigation/native";
+import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { Platform } from "react-native";
+import Toast from "react-native-toast-message";
-import { useColorScheme } from "@/hooks/useColorScheme";
-import { initializeApi } from "@/services/api";
+import { useSettingsStore } from "@/stores/settingsStore";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
- const colorScheme = useColorScheme();
+ const colorScheme = "dark";
const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
+ const initializeSettings = useSettingsStore((state) => state.loadSettings);
+
+ useEffect(() => {
+ initializeSettings();
+ }, [initializeSettings]);
useEffect(() => {
if (loaded || error) {
@@ -30,10 +31,6 @@ export default function RootLayout() {
}
}, [loaded, error]);
- useEffect(() => {
- initializeApi();
- }, []);
-
if (!loaded && !error) {
return null;
}
@@ -43,12 +40,11 @@ export default function RootLayout() {
- {Platform.OS !== "web" && (
-
- )}
+ {Platform.OS !== "web" && }
+
);
}
diff --git a/app/detail.tsx b/app/detail.tsx
index ed9915b..4ce1f08 100644
--- a/app/detail.tsx
+++ b/app/detail.tsx
@@ -1,11 +1,11 @@
-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';
+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 { StyledButton } from "@/components/StyledButton";
export default function DetailScreen() {
const { source, q } = useLocalSearchParams();
@@ -24,7 +24,7 @@ export default function DetailScreen() {
controllerRef.current = new AbortController();
const signal = controllerRef.current.signal;
- if (typeof q === 'string') {
+ if (typeof q === "string") {
const fetchDetailData = async () => {
setLoading(true);
setSearchResults([]);
@@ -35,15 +35,15 @@ export default function DetailScreen() {
try {
const resources = await api.getResources(signal);
if (!resources || resources.length === 0) {
- setError('没有可用的播放源');
+ 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 (typeof source === "string") {
+ const index = resources.findIndex((r) => r.key === source);
if (index > 0) {
resources.unshift(resources.splice(index, 1)[0]);
}
@@ -61,14 +61,14 @@ export default function DetailScreen() {
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
}
} catch (e) {
- if ((e as Error).name !== 'AbortError') {
+ if ((e as Error).name !== "AbortError") {
console.error(`Failed to get resolution for ${resource.name}`, e);
}
}
const resultWithResolution = { ...searchResult, resolution };
- setSearchResults(prev => [...prev, resultWithResolution]);
+ setSearchResults((prev) => [...prev, resultWithResolution]);
if (!foundFirstResult) {
setDetail(resultWithResolution);
@@ -77,19 +77,19 @@ export default function DetailScreen() {
}
}
} catch (e) {
- if ((e as Error).name !== 'AbortError') {
+ if ((e as Error).name !== "AbortError") {
console.error(`Error searching in resource ${resource.name}:`, e);
}
}
}
if (!foundFirstResult) {
- setError('未找到播放源');
+ setError("未找到播放源");
setLoading(false);
}
} catch (e) {
- if ((e as Error).name !== 'AbortError') {
- setError(e instanceof Error ? e.message : '获取资源列表失败');
+ if ((e as Error).name !== "AbortError") {
+ setError(e instanceof Error ? e.message : "获取资源列表失败");
setLoading(false);
}
} finally {
@@ -108,7 +108,7 @@ export default function DetailScreen() {
if (!detail) return;
controllerRef.current?.abort(); // Cancel any ongoing fetches
router.push({
- pathname: '/play',
+ pathname: "/play",
params: {
source: detail.source,
id: detail.id.toString(),
@@ -171,26 +171,27 @@ export default function DetailScreen() {
{searchResults.map((item, index) => (
- setDetail(item)}
hasTVPreferredFocus={index === 0}
- style={[styles.sourceButton, detail?.source === item.source && styles.sourceButtonSelected]}
+ isSelected={detail?.source === item.source}
+ style={styles.sourceButton}
>
{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}
)}
-
+
))}
@@ -198,9 +199,13 @@ export default function DetailScreen() {
播放列表
{detail.episodes.map((episode, index) => (
- handlePlay(episode, index)}>
- {`第 ${index + 1} 集`}
-
+ handlePlay(episode, index)}
+ text={`第 ${index + 1} 集`}
+ textStyle={styles.episodeButtonText}
+ />
))}
@@ -212,9 +217,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: {
@@ -225,20 +230,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,
},
@@ -247,7 +252,7 @@ const styles = StyleSheet.create({
},
description: {
fontSize: 14,
- color: '#ccc',
+ color: "#ccc",
lineHeight: 22,
},
bottomContainer: {
@@ -257,70 +262,53 @@ const styles = StyleSheet.create({
marginTop: 20,
},
sourcesTitleContainer: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
marginBottom: 10,
},
sourcesTitle: {
fontSize: 20,
- fontWeight: 'bold',
+ fontWeight: "bold",
},
sourceList: {
- flexDirection: 'row',
- flexWrap: 'wrap',
+ flexDirection: "row",
+ flexWrap: "wrap",
},
sourceButton: {
- backgroundColor: '#333',
- paddingHorizontal: 15,
- paddingVertical: 10,
- borderRadius: 8,
- margin: 5,
- flexDirection: 'row',
- alignItems: 'center',
- borderWidth: 2,
- borderColor: 'transparent',
- },
- sourceButtonSelected: {
- backgroundColor: '#007bff',
+ margin: 8,
},
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',
- paddingHorizontal: 20,
- paddingVertical: 10,
- borderRadius: 8,
margin: 5,
- borderWidth: 2,
- borderColor: 'transparent',
},
episodeButtonText: {
- color: 'white',
+ color: "white",
},
});
diff --git a/app/index.tsx b/app/index.tsx
index f0d64fa..68b2e86 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,209 +1,65 @@
-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;
- source: string;
- title: string;
- poster: string;
- progress?: number;
- lastPlayed?: number;
- episodeIndex?: number;
- sourceName?: string;
- totalEpisodes?: number;
- 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';
-
-// --- 类别定义 ---
-interface Category {
- title: string;
- 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: '日本动画' },
-];
+import React, { 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 VideoCard from "@/components/VideoCard.tv";
+import { useFocusEffect, useRouter } from "expo-router";
+import { Search, Settings } from "lucide-react-native";
+import { SettingsModal } from "@/components/SettingsModal";
+import { StyledButton } from "@/components/StyledButton";
+import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
+import { useSettingsStore } from "@/stores/settingsStore";
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() {
const router = useRouter();
- const colorScheme = useColorScheme();
-
- const [categories, setCategories] = useState(initialCategories);
- const [selectedCategory, setSelectedCategory] = useState(categories[0]);
- const [contentData, setContentData] = useState([]);
-
- 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);
-
+ const colorScheme = "dark";
const flatListRef = useRef(null);
- // --- 数据获取逻辑 ---
- const fetchPlayRecords = async () => {
- const records = await PlayRecordManager.getAll();
- return Object.entries(records)
- .map(([key, record]) => {
- const [source, id] = key.split('+');
- return {
- id,
- source,
- title: record.title,
- poster: record.cover,
- progress: record.play_time / record.total_time,
- lastPlayed: record.save_time,
- episodeIndex: record.index,
- sourceName: record.source_name,
- totalEpisodes: record.total_episodes,
- } as RowItem;
- })
- .filter(record => record.progress !== undefined && record.progress > 0 && record.progress < 1)
- .sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
- };
+ const {
+ categories,
+ selectedCategory,
+ contentData,
+ loading,
+ loadingMore,
+ error,
+ fetchInitialData,
+ loadMoreData,
+ selectCategory,
+ refreshPlayRecords,
+ } = useHomeStore();
- 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.filter(c => c.type !== 'record');
- setCategories(newCategories);
- if (newCategories.length > 0) {
- handleCategorySelect(newCategories[0]);
- }
- } else {
- setContentData(records);
- setHasMore(false);
- }
- setLoading(false);
- return;
- }
+ const showSettingsModal = useSettingsStore((state) => state.showModal);
- if (!category.type || !category.tag) return;
-
- setLoadingMore(start > 0);
- setError(null);
-
- try {
- 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 => ({
- ...item,
- id: item.title, // 临时ID
- source: 'douban',
- })) as RowItem[];
-
- 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 地址');
- } else {
- setError('加载失败,请重试');
- }
- } finally {
- setLoading(false);
- setLoadingMore(false);
- }
- };
-
- // --- Effects ---
useFocusEffect(
useCallback(() => {
- 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])
+ refreshPlayRecords();
+ }, [refreshPlayRecords])
);
useEffect(() => {
- loadInitialData();
- }, [selectedCategory]);
-
- const loadInitialData = (records?: RowItem[]) => {
- setLoading(true);
- setContentData([]);
- setPageStart(0);
- setHasMore(true);
+ fetchInitialData();
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
- fetchData(selectedCategory, 0, records);
- };
-
- const loadMoreData = () => {
- if (loading || loadingMore || !hasMore || selectedCategory.type === 'record') return;
- fetchData(selectedCategory, pageStart);
- };
+ }, [selectedCategory, fetchInitialData]);
const handleCategorySelect = (category: Category) => {
- setSelectedCategory(category);
+ selectCategory(category);
};
- // --- 渲染组件 ---
const renderCategory = ({ item }: { item: Category }) => {
- const isSelected = selectedCategory.title === item.title;
+ const isSelected = selectedCategory?.title === item.title;
return (
- [
- styles.categoryButton,
- isSelected && styles.categoryButtonSelected,
- focused && styles.categoryButtonFocused,
- ]}
+ handleCategorySelect(item)}
- >
- {item.title}
-
+ isSelected={isSelected}
+ style={styles.categoryButton}
+ textStyle={styles.categoryText}
+ />
);
};
@@ -217,11 +73,12 @@ export default function HomeScreen() {
year={item.year}
rate={item.rate}
progress={item.progress}
+ playTime={item.play_time}
episodeIndex={item.episodeIndex}
sourceName={item.sourceName}
totalEpisodes={item.totalEpisodes}
api={api}
- onRecordDeleted={loadInitialData} // For "Recent Plays"
+ onRecordDeleted={fetchInitialData} // For "Recent Plays"
/>
);
@@ -237,18 +94,16 @@ export default function HomeScreen() {
首页
- [styles.searchButton, focused && styles.searchButtonFocused]}
- onPress={() => router.push({ pathname: '/search' })}
+ router.push({ pathname: "/search" })}
+ variant="ghost"
>
-
-
- [styles.searchButton, focused && styles.searchButtonFocused]}
- onPress={() => setSettingsVisible(true)}
- >
-
-
+
+
+
+
+
@@ -257,7 +112,7 @@ export default function HomeScreen() {
item.title}
+ keyExtractor={(item) => item.title}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoryListContent}
@@ -293,14 +148,7 @@ export default function HomeScreen() {
}
/>
)}
- setSettingsVisible(false)}
- onSave={() => {
- setSettingsVisible(false);
- loadInitialData();
- }}
- />
+
);
}
@@ -313,61 +161,47 @@ const styles = StyleSheet.create({
centerContainer: {
flex: 1,
paddingTop: 20,
- justifyContent: 'center',
- alignItems: 'center',
+ 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,
borderRadius: 30,
marginLeft: 10,
},
- searchButtonFocused: {
- backgroundColor: '#007AFF',
- transform: [{ scale: 1.1 }],
- },
// Category Selector
categoryContainer: {
- paddingBottom: 10,
+ paddingBottom: 6,
},
categoryListContent: {
paddingHorizontal: 16,
},
categoryButton: {
- paddingHorizontal: 12,
+ paddingHorizontal: 2,
paddingVertical: 6,
borderRadius: 8,
- marginHorizontal: 5,
- },
- categoryButtonSelected: {
- backgroundColor: '#007AFF', // A bright blue for selected state
- },
- categoryButtonFocused: {
- backgroundColor: '#0056b3', // A darker blue for focused state
- elevation: 5,
+ marginHorizontal: 6,
},
categoryText: {
fontSize: 16,
- fontWeight: '500',
- },
- categoryTextSelected: {
- color: '#FFFFFF',
+ fontWeight: "500",
},
// Content Grid
listContent: {
@@ -377,6 +211,6 @@ const styles = StyleSheet.create({
itemContainer: {
margin: 8,
width: ITEM_WIDTH,
- alignItems: 'center',
+ alignItems: "center",
},
});
diff --git a/app/play.tsx b/app/play.tsx
index 94d45ec..655068b 100644
--- a/app/play.tsx
+++ b/app/play.tsx
@@ -1,101 +1,87 @@
-import React, { useState, useRef } from "react";
-import {
- View,
- StyleSheet,
- TouchableOpacity,
- ActivityIndicator,
-} from "react-native";
-import { useRouter } from "expo-router";
+import React, { useEffect, useRef } from "react";
+import { View, StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler } from "react-native";
+import { useLocalSearchParams, useRouter } from "expo-router";
import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
import { ThemedView } from "@/components/ThemedView";
import { PlayerControls } from "@/components/PlayerControls";
import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
+import { SourceSelectionModal } from "@/components/SourceSelectionModal";
+import { SeekingBar } from "@/components/SeekingBar";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import { LoadingOverlay } from "@/components/LoadingOverlay";
-import { usePlaybackManager } from "@/hooks/usePlaybackManager";
+import usePlayerStore from "@/stores/playerStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
export default function PlayScreen() {
- const router = useRouter();
const videoRef = useRef