This commit is contained in:
Neil.X.Zhang
2025-07-02 09:09:35 +08:00
parent 3b79d06b7d
commit 05ef835d5d
86 changed files with 2440 additions and 8770 deletions

View File

@@ -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>

View File

@@ -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",
},
});

View File

@@ -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
View 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
View 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,
},
});