mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-16 05:04:42 +08:00
Update
This commit is contained in:
@@ -7,6 +7,7 @@ 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 { useColorScheme } from "@/hooks/useColorScheme";
|
||||
|
||||
@@ -36,6 +37,11 @@ export default function RootLayout() {
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="detail" options={{ headerShown: false }} />
|
||||
{Platform.OS !== "web" && (
|
||||
<Stack.Screen name="play" options={{ headerShown: false }} />
|
||||
)}
|
||||
<Stack.Screen name="search" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
|
||||
306
app/detail.tsx
306
app/detail.tsx
@@ -1,33 +1,305 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
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";
|
||||
|
||||
export default function DetailScreen() {
|
||||
const { source, id } = useLocalSearchParams();
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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
|
||||
|
||||
// Asynchronously fetch resolutions
|
||||
const resultsWithResolutions = await Promise.all(
|
||||
results.map(async (searchResult) => {
|
||||
try {
|
||||
if (
|
||||
searchResult.episodes &&
|
||||
searchResult.episodes.length > 0
|
||||
) {
|
||||
const resolution = await getResolutionFromM3U8(
|
||||
searchResult.episodes[0]
|
||||
);
|
||||
return { ...searchResult, resolution };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get resolution for source", e);
|
||||
}
|
||||
return searchResult; // Return original if fails
|
||||
})
|
||||
);
|
||||
setSearchResults(resultsWithResolutions);
|
||||
} else {
|
||||
setError("未找到播放源");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "获取详情失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDetailData();
|
||||
}
|
||||
}, [source, q]);
|
||||
|
||||
const handlePlay = (episodeName: string, episodeIndex: number) => {
|
||||
if (!detail) return;
|
||||
router.push({
|
||||
pathname: "/play",
|
||||
params: {
|
||||
source: detail.source,
|
||||
id: detail.id.toString(),
|
||||
episodeUrl: episodeName, // The "episode" is actually the URL
|
||||
episodeIndex: episodeIndex.toString(),
|
||||
title: detail.title,
|
||||
poster: detail.poster,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.centered}>
|
||||
<ActivityIndicator size="large" />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ThemedView style={styles.centered}>
|
||||
<ThemedText type="subtitle">Error: {error}</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<ThemedView style={styles.centered}>
|
||||
<ThemedText type="subtitle">未找到详情信息</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">Detail Page</ThemedText>
|
||||
<View style={styles.separator} />
|
||||
<ThemedText>Source: {source}</ThemedText>
|
||||
<ThemedText>ID: {id}</ThemedText>
|
||||
<ScrollView>
|
||||
<View style={styles.topContainer}>
|
||||
<Image source={{ uri: detail.poster }} style={styles.poster} />
|
||||
<View style={styles.infoContainer}>
|
||||
<ThemedText style={styles.title} numberOfLines={1}>
|
||||
{detail.title}
|
||||
</ThemedText>
|
||||
<View style={styles.metaContainer}>
|
||||
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
|
||||
<ThemedText style={styles.metaText}>
|
||||
{detail.type_name}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<ScrollView style={styles.descriptionScrollView}>
|
||||
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomContainer}>
|
||||
<View style={styles.sourcesContainer}>
|
||||
<ThemedText style={styles.sourcesTitle}>
|
||||
选择播放源 共 {searchResults.length} 个
|
||||
</ThemedText>
|
||||
<View style={styles.sourceList}>
|
||||
{searchResults.map((item, index) => (
|
||||
<DetailButton
|
||||
key={index}
|
||||
onPress={() => setDetail(item)}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
style={[
|
||||
styles.sourceButton,
|
||||
detail?.source === item.source &&
|
||||
styles.sourceButtonSelected,
|
||||
]}
|
||||
>
|
||||
<ThemedText style={styles.sourceButtonText}>
|
||||
{item.source_name}
|
||||
</ThemedText>
|
||||
{item.episodes.length > 1 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>
|
||||
{item.episodes.length > 99
|
||||
? "99+"
|
||||
: `${item.episodes.length}`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.resolution && (
|
||||
<View
|
||||
style={[styles.badge, { backgroundColor: "#28a745" }]}
|
||||
>
|
||||
<Text style={styles.badgeText}>{item.resolution}</Text>
|
||||
</View>
|
||||
)}
|
||||
</DetailButton>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.episodesContainer}>
|
||||
<ThemedText style={styles.episodesTitle}>播放列表</ThemedText>
|
||||
<ScrollView contentContainerStyle={styles.episodeList}>
|
||||
{detail.episodes.map((episode, index) => (
|
||||
<DetailButton
|
||||
key={index}
|
||||
style={styles.episodeButton}
|
||||
onPress={() => handlePlay(episode, index)}
|
||||
>
|
||||
<ThemedText style={styles.episodeButtonText}>{`第 ${
|
||||
index + 1
|
||||
} 集`}</ThemedText>
|
||||
</DetailButton>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
container: { flex: 1 },
|
||||
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
|
||||
topContainer: {
|
||||
flexDirection: "row",
|
||||
padding: 20,
|
||||
},
|
||||
separator: {
|
||||
marginVertical: 30,
|
||||
height: 1,
|
||||
width: "80%",
|
||||
backgroundColor: "#666",
|
||||
poster: {
|
||||
width: 200,
|
||||
height: 300,
|
||||
borderRadius: 8,
|
||||
},
|
||||
infoContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 20,
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 10,
|
||||
paddingTop: 20,
|
||||
},
|
||||
metaContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 10,
|
||||
},
|
||||
metaText: {
|
||||
color: "#aaa",
|
||||
marginRight: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
descriptionScrollView: {
|
||||
height: 150, // Constrain height to make it scrollable
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: "#ccc",
|
||||
lineHeight: 22,
|
||||
},
|
||||
bottomContainer: {
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
sourcesContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
sourcesTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 10,
|
||||
},
|
||||
sourceList: {
|
||||
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",
|
||||
},
|
||||
sourceButtonText: {
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: "red",
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
marginLeft: 8,
|
||||
},
|
||||
badgeText: {
|
||||
color: "white",
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
episodesContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
episodesTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 10,
|
||||
},
|
||||
episodeList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
episodeButton: {
|
||||
backgroundColor: "#333",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
margin: 5,
|
||||
borderWidth: 2,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
episodeButtonText: {
|
||||
color: "white",
|
||||
},
|
||||
});
|
||||
|
||||
397
app/index.tsx
397
app/index.tsx
@@ -1,98 +1,302 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, StyleSheet, ActivityIndicator, FlatList } from "react-native";
|
||||
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 ScrollableRow from "@/components/ScrollableRow.tv";
|
||||
import { MoonTVAPI, DoubanResponse } from "@/services/api";
|
||||
import { RowItem } from "@/components/ScrollableRow.tv";
|
||||
import { moonTVApi } from "@/services/api";
|
||||
import { SearchResult } from "@/services/api";
|
||||
import { PlayRecord } from "@/services/storage";
|
||||
|
||||
interface ContentRow {
|
||||
export type RowItem = (SearchResult | PlayRecord) & {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
data: RowItem[];
|
||||
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 } from "lucide-react-native";
|
||||
|
||||
// --- 类别定义 ---
|
||||
interface Category {
|
||||
title: string;
|
||||
type?: "movie" | "tv" | "record";
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ title: "热门电影", type: "movie", tag: "热门" },
|
||||
{ title: "热门剧集", type: "tv", tag: "热门" },
|
||||
{ title: "豆瓣 Top250", type: "movie", tag: "top250" },
|
||||
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: "tv", tag: "美剧" },
|
||||
{ title: "韩剧", type: "tv", tag: "韩剧" },
|
||||
{ title: "日剧", type: "tv", tag: "日剧" },
|
||||
{ title: "日漫", type: "tv", tag: "日本动画" },
|
||||
] as const;
|
||||
];
|
||||
|
||||
// --- IMPORTANT ---
|
||||
// Replace with your computer's LAN IP address to test on a real device or emulator.
|
||||
// Find it by running `ifconfig` (macOS/Linux) or `ipconfig` (Windows).
|
||||
const API_BASE_URL = "http://192.168.31.123:3001";
|
||||
const api = new MoonTVAPI(API_BASE_URL);
|
||||
const NUM_COLUMNS = 5;
|
||||
const { width } = Dimensions.get("window");
|
||||
const ITEM_WIDTH = width / NUM_COLUMNS - 24;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [rows, setRows] = useState<ContentRow[]>([]);
|
||||
const router = useRouter();
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>(initialCategories);
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category>(
|
||||
categories[0]
|
||||
);
|
||||
const [contentData, setContentData] = useState<RowItem[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const promises = categories.map((category) =>
|
||||
api.getDoubanData(category.type, category.tag, 20)
|
||||
);
|
||||
const results = await Promise.all<DoubanResponse>(promises);
|
||||
const [pageStart, setPageStart] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const newRows: ContentRow[] = results.map((result, index) => {
|
||||
const category = categories[index];
|
||||
return {
|
||||
title: category.title,
|
||||
data: result.list.map((item) => ({
|
||||
...item,
|
||||
id: item.title, // Use title as a temporary unique id
|
||||
source: "douban", // Static source for douban items
|
||||
})),
|
||||
};
|
||||
});
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
setRows(newRows);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch data for home screen:", err);
|
||||
setError("无法加载内容,请稍后重试。");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// --- 数据获取逻辑 ---
|
||||
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 fetchData = async (category: Category, start: number) => {
|
||||
if (category.type === "record") {
|
||||
const records = await fetchPlayRecords();
|
||||
if (records.length === 0 && categories[0].type === "record") {
|
||||
// 如果没有播放记录,则移除"最近播放"分类并选择第一个真实分类
|
||||
const newCategories = categories.slice(1);
|
||||
setCategories(newCategories);
|
||||
handleCategorySelect(newCategories[0]);
|
||||
} else {
|
||||
setContentData(records);
|
||||
setHasMore(false);
|
||||
}
|
||||
};
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchAllData();
|
||||
}, []);
|
||||
if (!category.type || !category.tag) return;
|
||||
|
||||
if (loading) {
|
||||
setLoadingMore(start > 0);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await moonTVApi.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) {
|
||||
console.error("Failed to load data:", err);
|
||||
setError("加载失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Effects ---
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (selectedCategory.type === "record") {
|
||||
loadInitialData();
|
||||
}
|
||||
}, [selectedCategory])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, [selectedCategory]);
|
||||
|
||||
const loadInitialData = () => {
|
||||
setLoading(true);
|
||||
setContentData([]);
|
||||
setPageStart(0);
|
||||
setHasMore(true);
|
||||
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
|
||||
fetchData(selectedCategory, 0);
|
||||
};
|
||||
|
||||
const loadMoreData = () => {
|
||||
if (
|
||||
loading ||
|
||||
loadingMore ||
|
||||
!hasMore ||
|
||||
selectedCategory.type === "record"
|
||||
)
|
||||
return;
|
||||
fetchData(selectedCategory, pageStart);
|
||||
};
|
||||
|
||||
const handleCategorySelect = (category: Category) => {
|
||||
setSelectedCategory(category);
|
||||
};
|
||||
|
||||
// --- 渲染组件 ---
|
||||
const renderCategory = ({ item }: { item: Category }) => {
|
||||
const isSelected = selectedCategory.title === item.title;
|
||||
return (
|
||||
<ThemedView style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
</ThemedView>
|
||||
<Pressable
|
||||
style={({ focused }) => [
|
||||
styles.categoryButton,
|
||||
isSelected && styles.categoryButtonSelected,
|
||||
focused && styles.categoryButtonFocused,
|
||||
]}
|
||||
onPress={() => handleCategorySelect(item)}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.categoryText,
|
||||
isSelected && styles.categoryTextSelected,
|
||||
]}
|
||||
>
|
||||
{item.title}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ThemedView style={styles.centerContainer}>
|
||||
<ThemedText type="subtitle">{error}</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
const renderContentItem = ({ item }: { item: RowItem }) => (
|
||||
<View style={styles.itemContainer}>
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
source={item.source}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
year={item.year}
|
||||
rate={item.rate}
|
||||
progress={item.progress}
|
||||
episodeIndex={item.episodeIndex}
|
||||
sourceName={item.sourceName}
|
||||
totalEpisodes={item.totalEpisodes}
|
||||
api={moonTVApi}
|
||||
onRecordDeleted={loadInitialData} // For "Recent Plays"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!loadingMore) return null;
|
||||
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<FlatList
|
||||
data={rows}
|
||||
renderItem={({ item }) => (
|
||||
<ScrollableRow title={item.title} data={item.data} api={api} />
|
||||
)}
|
||||
keyExtractor={(item) => item.title}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
{/* 顶部导航 */}
|
||||
<View style={styles.headerContainer}>
|
||||
<ThemedText style={styles.headerTitle}>首页</ThemedText>
|
||||
<Pressable
|
||||
style={({ focused }) => [
|
||||
styles.searchButton,
|
||||
focused && styles.searchButtonFocused,
|
||||
]}
|
||||
onPress={() => router.push({ pathname: "/search" })}
|
||||
>
|
||||
<Search
|
||||
color={colorScheme === "dark" ? "white" : "black"}
|
||||
size={24}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 分类选择器 */}
|
||||
<View style={styles.categoryContainer}>
|
||||
<FlatList
|
||||
data={categories}
|
||||
renderItem={renderCategory}
|
||||
keyExtractor={(item) => item.title}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoryListContent}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 内容网格 */}
|
||||
{loading ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ThemedText type="subtitle">{error}</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={contentData}
|
||||
renderItem={renderContentItem}
|
||||
keyExtractor={(item, index) => `${item.source}-${item.id}-${index}`}
|
||||
numColumns={NUM_COLUMNS}
|
||||
contentContainerStyle={styles.listContent}
|
||||
onEndReached={loadMoreData}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.centerContainer}>
|
||||
<ThemedText>该分类下暂无内容</ThemedText>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
@@ -100,14 +304,69 @@ export default function HomeScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 40,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
// Header
|
||||
headerContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 10,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
paddingTop: 16,
|
||||
},
|
||||
searchButton: {
|
||||
padding: 10,
|
||||
borderRadius: 30,
|
||||
},
|
||||
searchButtonFocused: {
|
||||
backgroundColor: "#007AFF",
|
||||
transform: [{ scale: 1.1 }],
|
||||
},
|
||||
// Category Selector
|
||||
categoryContainer: {
|
||||
paddingBottom: 10,
|
||||
},
|
||||
categoryListContent: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
categoryButton: {
|
||||
paddingHorizontal: 12,
|
||||
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,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
categoryTextSelected: {
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
// Content Grid
|
||||
listContent: {
|
||||
paddingTop: 40,
|
||||
paddingBottom: 40,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
itemContainer: {
|
||||
margin: 8,
|
||||
width: ITEM_WIDTH,
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
||||
194
app/play.tsx
Normal file
194
app/play.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { 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 { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
|
||||
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
|
||||
|
||||
export default function PlayScreen() {
|
||||
const router = useRouter();
|
||||
const videoRef = useRef<Video>(null);
|
||||
useKeepAwake();
|
||||
|
||||
const {
|
||||
detail,
|
||||
episodes,
|
||||
currentEpisodeIndex,
|
||||
status,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
showNextEpisodeOverlay,
|
||||
playEpisode,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
handlePlaybackStatusUpdate,
|
||||
setShowNextEpisodeOverlay,
|
||||
} = usePlaybackManager(videoRef);
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [showEpisodeModal, setShowEpisodeModal] = useState(false);
|
||||
const [episodeGroupSize] = useState(30);
|
||||
const [selectedEpisodeGroup, setSelectedEpisodeGroup] = useState(
|
||||
Math.floor(currentEpisodeIndex / episodeGroupSize)
|
||||
);
|
||||
|
||||
const { currentFocus, setCurrentFocus } = useTVRemoteHandler({
|
||||
showControls,
|
||||
setShowControls,
|
||||
showEpisodeModal,
|
||||
onPlayPause: togglePlayPause,
|
||||
onSeek: seek,
|
||||
onShowEpisodes: () => setShowEpisodeModal(true),
|
||||
onPlayNextEpisode: () => {
|
||||
if (currentEpisodeIndex < episodes.length - 1) {
|
||||
playEpisode(currentEpisodeIndex + 1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const [seekPosition, setSeekPosition] = useState(0);
|
||||
const [progressPosition, setProgressPosition] = useState(0);
|
||||
|
||||
const formatTime = (milliseconds: number) => {
|
||||
if (!milliseconds) return "00:00";
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, "0")}:${seconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const handleSeekStart = () => setIsSeeking(true);
|
||||
|
||||
const handleSeekMove = (event: { nativeEvent: { locationX: number } }) => {
|
||||
if (!status?.isLoaded || !status.durationMillis) return;
|
||||
const { locationX } = event.nativeEvent;
|
||||
const progressBarWidth = 300;
|
||||
const progress = Math.max(0, Math.min(locationX / progressBarWidth, 1));
|
||||
setSeekPosition(progress);
|
||||
};
|
||||
|
||||
const handleSeekRelease = (event: { nativeEvent: { locationX: number } }) => {
|
||||
if (!videoRef.current || !status?.isLoaded || !status.durationMillis)
|
||||
return;
|
||||
const wasPlaying = status.isPlaying;
|
||||
const { locationX } = event.nativeEvent;
|
||||
const progressBarWidth = 300;
|
||||
const progress = Math.max(0, Math.min(locationX / progressBarWidth, 1));
|
||||
const newPosition = progress * status.durationMillis;
|
||||
videoRef.current.setPositionAsync(newPosition).then(() => {
|
||||
if (wasPlaying) {
|
||||
videoRef.current?.playAsync();
|
||||
}
|
||||
});
|
||||
setIsSeeking(false);
|
||||
};
|
||||
|
||||
if (!detail && isLoading) {
|
||||
return (
|
||||
<ThemedView style={[styles.container, styles.centered]}>
|
||||
<ActivityIndicator size="large" color="#fff" />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const currentEpisode = episodes[currentEpisodeIndex];
|
||||
const videoTitle = detail?.videoInfo?.title || "";
|
||||
const hasNextEpisode = currentEpisodeIndex < episodes.length - 1;
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={styles.videoContainer}
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
setCurrentFocus(null);
|
||||
}}
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
style={styles.videoPlayer}
|
||||
source={{ uri: currentEpisode?.url }}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
onPlaybackStatusUpdate={(s) => {
|
||||
handlePlaybackStatusUpdate(s);
|
||||
if (s.isLoaded && !isSeeking) {
|
||||
setProgressPosition(s.positionMillis / (s.durationMillis || 1));
|
||||
}
|
||||
}}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
useNativeControls={false}
|
||||
shouldPlay
|
||||
/>
|
||||
|
||||
{showControls && (
|
||||
<PlayerControls
|
||||
videoTitle={videoTitle}
|
||||
currentEpisodeTitle={currentEpisode?.title}
|
||||
status={status}
|
||||
isSeeking={isSeeking}
|
||||
seekPosition={seekPosition}
|
||||
progressPosition={progressPosition}
|
||||
currentFocus={currentFocus}
|
||||
hasNextEpisode={hasNextEpisode}
|
||||
onSeekStart={handleSeekStart}
|
||||
onSeekMove={handleSeekMove}
|
||||
onSeekRelease={handleSeekRelease}
|
||||
onSeek={seek}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onPlayNextEpisode={() => playEpisode(currentEpisodeIndex + 1)}
|
||||
onShowEpisodes={() => setShowEpisodeModal(true)}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<NextEpisodeOverlay
|
||||
visible={showNextEpisodeOverlay}
|
||||
onCancel={() => setShowNextEpisodeOverlay(false)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<EpisodeSelectionModal
|
||||
visible={showEpisodeModal}
|
||||
episodes={episodes}
|
||||
currentEpisodeIndex={currentEpisodeIndex}
|
||||
episodeGroupSize={episodeGroupSize}
|
||||
selectedEpisodeGroup={selectedEpisodeGroup}
|
||||
setSelectedEpisodeGroup={setSelectedEpisodeGroup}
|
||||
onSelectEpisode={(index) => {
|
||||
playEpisode(index);
|
||||
setShowEpisodeModal(false);
|
||||
}}
|
||||
onClose={() => setShowEpisodeModal(false)}
|
||||
/>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "black" },
|
||||
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
|
||||
videoContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
videoPlayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
});
|
||||
175
app/search.tsx
Normal file
175
app/search.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
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";
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textInputRef = useRef<TextInput>(null);
|
||||
const colorScheme = useColorScheme();
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus the text input when the screen loads
|
||||
const timer = setTimeout(() => {
|
||||
textInputRef.current?.focus();
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!keyword.trim()) {
|
||||
Keyboard.dismiss();
|
||||
return;
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await moonTVApi.searchVideos(keyword);
|
||||
setResults(response.results);
|
||||
} catch (err) {
|
||||
setError("搜索失败,请稍后重试。");
|
||||
console.error("Search failed:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: SearchResult }) => (
|
||||
<VideoCard
|
||||
id={item.id.toString()}
|
||||
source={item.source}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
year={item.year}
|
||||
sourceName={item.source_name}
|
||||
api={moonTVApi}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<View style={styles.searchContainer}>
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0",
|
||||
color: colorScheme === "dark" ? "white" : "black",
|
||||
borderColor: isInputFocused ? "#007bff" : "transparent",
|
||||
},
|
||||
]}
|
||||
placeholder="搜索电影、剧集..."
|
||||
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onSubmitEditing={handleSearch} // Allow searching with remote 'enter' button
|
||||
returnKeyType="search"
|
||||
/>
|
||||
<Pressable
|
||||
style={({ focused }) => [
|
||||
styles.searchButton,
|
||||
{
|
||||
backgroundColor: colorScheme === "dark" ? "#3a3a3c" : "#e0e0e0",
|
||||
},
|
||||
focused && styles.focusedButton,
|
||||
]}
|
||||
onPress={handleSearch}
|
||||
>
|
||||
<Search
|
||||
size={24}
|
||||
color={colorScheme === "dark" ? "white" : "black"}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ThemedText style={styles.errorText}>{error}</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={results}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `${item.id}-${item.source}-${index}`}
|
||||
numColumns={5} // Adjust based on your card size and desired layout
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.centerContainer}>
|
||||
<ThemedText>输入关键词开始搜索</ThemedText>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 50,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 50,
|
||||
backgroundColor: "#2c2c2e", // Default for dark mode, overridden inline
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 15,
|
||||
color: "white", // Default for dark mode, overridden inline
|
||||
fontSize: 18,
|
||||
marginRight: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: "transparent", // Default, overridden for focus
|
||||
},
|
||||
searchButton: {
|
||||
padding: 12,
|
||||
// backgroundColor is now set dynamically
|
||||
borderRadius: 8,
|
||||
},
|
||||
focusedButton: {
|
||||
backgroundColor: "#007bff",
|
||||
transform: [{ scale: 1.1 }],
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: "red",
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user