mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 03:36:29 +08:00
Refactor & Fix issue
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
.roomodes
|
||||
247
app/detail.tsx
247
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<string | null>(null);
|
||||
const [allSourcesLoaded, setAllSourcesLoaded] = useState(false);
|
||||
const controllerRef = useRef<AbortController | 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
|
||||
if (controllerRef.current) {
|
||||
controllerRef.current.abort();
|
||||
}
|
||||
controllerRef.current = new AbortController();
|
||||
const signal = controllerRef.current.signal;
|
||||
|
||||
if (typeof q === 'string') {
|
||||
const fetchDetailData = async () => {
|
||||
setLoading(true);
|
||||
setSearchResults([]);
|
||||
setDetail(null);
|
||||
setError(null);
|
||||
setAllSourcesLoaded(false);
|
||||
|
||||
// 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 {
|
||||
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) {
|
||||
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'AbortError') {
|
||||
console.error(`Failed to get resolution for ${resource.name}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if ((e as Error).name !== 'AbortError') {
|
||||
setError(e instanceof Error ? e.message : '获取资源列表失败');
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
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() {
|
||||
</ThemedText>
|
||||
<View style={styles.metaContainer}>
|
||||
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
|
||||
<ThemedText style={styles.metaText}>
|
||||
{detail.type_name}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
|
||||
</View>
|
||||
<ScrollView style={styles.descriptionScrollView}>
|
||||
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
|
||||
@@ -133,37 +165,28 @@ export default function DetailScreen() {
|
||||
|
||||
<View style={styles.bottomContainer}>
|
||||
<View style={styles.sourcesContainer}>
|
||||
<ThemedText style={styles.sourcesTitle}>
|
||||
选择播放源 共 {searchResults.length} 个
|
||||
</ThemedText>
|
||||
<View style={styles.sourcesTitleContainer}>
|
||||
<ThemedText style={styles.sourcesTitle}>选择播放源 共 {searchResults.length} 个</ThemedText>
|
||||
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
|
||||
</View>
|
||||
<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,
|
||||
]}
|
||||
style={[styles.sourceButton, detail?.source === item.source && styles.sourceButtonSelected]}
|
||||
>
|
||||
<ThemedText style={styles.sourceButtonText}>
|
||||
{item.source_name}
|
||||
</ThemedText>
|
||||
<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}`}
|
||||
{item.episodes.length > 99 ? '99+' : `${item.episodes.length}`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.resolution && (
|
||||
<View
|
||||
style={[styles.badge, { backgroundColor: "#28a745" }]}
|
||||
>
|
||||
<View style={[styles.badge, { backgroundColor: '#28a745' }]}>
|
||||
<Text style={styles.badgeText}>{item.resolution}</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -175,14 +198,8 @@ export default function DetailScreen() {
|
||||
<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 key={index} style={styles.episodeButton} onPress={() => handlePlay(episode, index)}>
|
||||
<ThemedText style={styles.episodeButtonText}>{`第 ${index + 1} 集`}</ThemedText>
|
||||
</DetailButton>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
203
app/index.tsx
203
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<Category[]>(initialCategories);
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category>(
|
||||
categories[0]
|
||||
);
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category>(categories[0]);
|
||||
const [contentData, setContentData] = useState<RowItem[]>([]);
|
||||
|
||||
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);
|
||||
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)}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.categoryText,
|
||||
isSelected && styles.categoryTextSelected,
|
||||
]}
|
||||
>
|
||||
{item.title}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.categoryText, isSelected && styles.categoryTextSelected]}>{item.title}</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
</View>
|
||||
@@ -252,28 +238,16 @@ export default function HomeScreen() {
|
||||
<ThemedText style={styles.headerTitle}>首页</ThemedText>
|
||||
<View style={styles.rightHeaderButtons}>
|
||||
<Pressable
|
||||
style={({ focused }) => [
|
||||
styles.searchButton,
|
||||
focused && styles.searchButtonFocused,
|
||||
]}
|
||||
onPress={() => router.push({ pathname: "/search" })}
|
||||
style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]}
|
||||
onPress={() => router.push({ pathname: '/search' })}
|
||||
>
|
||||
<Search
|
||||
color={colorScheme === "dark" ? "white" : "black"}
|
||||
size={24}
|
||||
/>
|
||||
<Search color={colorScheme === 'dark' ? 'white' : 'black'} size={24} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ focused }) => [
|
||||
styles.searchButton,
|
||||
focused && styles.searchButtonFocused,
|
||||
]}
|
||||
style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]}
|
||||
onPress={() => setSettingsVisible(true)}
|
||||
>
|
||||
<Settings
|
||||
color={colorScheme === "dark" ? "white" : "black"}
|
||||
size={24}
|
||||
/>
|
||||
<Settings color={colorScheme === 'dark' ? 'white' : 'black'} size={24} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -283,7 +257,7 @@ export default function HomeScreen() {
|
||||
<FlatList
|
||||
data={categories}
|
||||
renderItem={renderCategory}
|
||||
keyExtractor={(item) => 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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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}
|
||||
>
|
||||
<Search
|
||||
size={24}
|
||||
color={colorScheme === "dark" ? "white" : "black"}
|
||||
/>
|
||||
<Search size={24} color={colorScheme === 'dark' ? 'white' : 'black'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "魔都资源"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SettingsModalProps> = ({
|
||||
visible,
|
||||
onCancel,
|
||||
onSave,
|
||||
}) => {
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({ visible, onCancel, onSave }) => {
|
||||
const [apiUrl, setApiUrl] = useState('');
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const colorScheme = useColorScheme();
|
||||
const inputRef = useRef<TextInput>(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<SettingsModalProps> = ({
|
||||
|
||||
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<SettingsModalProps> = ({
|
||||
},
|
||||
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<SettingsModalProps> = ({
|
||||
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<SettingsModalProps> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={visible}
|
||||
onRequestClose={onCancel}
|
||||
>
|
||||
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={onCancel}>
|
||||
<View style={styles.modalContainer}>
|
||||
<ThemedView style={styles.modalContent}>
|
||||
<ThemedText style={styles.title}>设置</ThemedText>
|
||||
@@ -134,7 +117,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
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<SettingsModalProps> = ({
|
||||
/>
|
||||
<View style={styles.buttonContainer}>
|
||||
<Pressable
|
||||
style={({ focused }) => [
|
||||
styles.button,
|
||||
styles.buttonCancel,
|
||||
focused && styles.focusedButton,
|
||||
]}
|
||||
style={({ focused }) => [styles.button, styles.buttonCancel, focused && styles.focusedButton]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.buttonText}>取消</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ focused }) => [
|
||||
styles.button,
|
||||
styles.buttonSave,
|
||||
focused && styles.focusedButton,
|
||||
]}
|
||||
style={({ focused }) => [styles.button, styles.buttonSave, focused && styles.focusedButton]}
|
||||
onPress={handleSave}
|
||||
>
|
||||
<Text style={styles.buttonText}>保存</Text>
|
||||
|
||||
@@ -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 (
|
||||
<Animated.View style={[styles.wrapper, animatedStyle]}>
|
||||
@@ -146,18 +133,13 @@ export default function VideoCard({
|
||||
delayLongPress={1000}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<Image
|
||||
source={{ uri: api.getImageProxyUrl(poster) }}
|
||||
style={styles.poster}
|
||||
/>
|
||||
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
|
||||
{isFocused && (
|
||||
<View style={styles.overlay}>
|
||||
{isContinueWatching && (
|
||||
<View style={styles.continueWatchingBadge}>
|
||||
<Play size={16} color="#ffffff" fill="#ffffff" />
|
||||
<ThemedText style={styles.continueWatchingText}>
|
||||
继续观看
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.continueWatchingText}>继续观看</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -166,12 +148,7 @@ export default function VideoCard({
|
||||
{/* 进度条 */}
|
||||
{isContinueWatching && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ width: `${(progress || 0) * 100}%` },
|
||||
]}
|
||||
/>
|
||||
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -197,8 +174,7 @@ export default function VideoCard({
|
||||
{isContinueWatching && !isFocused && (
|
||||
<View style={styles.infoRow}>
|
||||
<ThemedText style={styles.continueLabel}>
|
||||
第{episodeIndex! + 1}集 已观看{" "}
|
||||
{Math.round((progress || 0) * 100)}%
|
||||
第{episodeIndex! + 1}集 已观看 {Math.round((progress || 0) * 100)}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<Video>) => {
|
||||
const source = (params.source as string) || "1";
|
||||
const id = (params.id as string) || "1";
|
||||
|
||||
const data = await moonTVApi.getVideoDetail(source, id);
|
||||
const data = await api.getVideoDetail(source, id);
|
||||
setDetail(data);
|
||||
|
||||
const processedEpisodes = data.episodes.map((url, index) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "OrionTV",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"scripts": {
|
||||
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||
|
||||
@@ -58,6 +58,13 @@ export interface PlayRecord {
|
||||
user_id: number; // User ID, always 0 in this version
|
||||
}
|
||||
|
||||
export interface ApiSite {
|
||||
key: string;
|
||||
api: string;
|
||||
name: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export class API {
|
||||
public baseURL: string = "";
|
||||
|
||||
@@ -116,6 +123,27 @@ export class API {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async searchVideo(query: string, resourceId: string, signal?: AbortSignal): Promise<{ results: SearchResult[] }> {
|
||||
if (!this.baseURL) {
|
||||
throw new Error("API_URL_NOT_SET");
|
||||
}
|
||||
const url = `${this.baseURL}/api/search/one?q=${encodeURIComponent(query)}&resourceId=${encodeURIComponent(resourceId)}`;
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getResources(signal?: AbortSignal): Promise<ApiSite[]> {
|
||||
if (!this.baseURL) {
|
||||
throw new Error("API_URL_NOT_SET");
|
||||
}
|
||||
const url = `${this.baseURL}/api/search/resources`;
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取视频详情
|
||||
*/
|
||||
@@ -128,53 +156,13 @@ export class API {
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
async login(password: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = `${this.baseURL}/api/login`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有播放记录
|
||||
*/
|
||||
async getPlayRecords(): Promise<Record<string, PlayRecord>> {
|
||||
const url = `${this.baseURL}/api/playrecords`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存播放记录
|
||||
*/
|
||||
async savePlayRecord(
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<{ success: boolean }> {
|
||||
const url = `${this.baseURL}/api/playrecords`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, record }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
// 默认实例
|
||||
export let moonTVApi = new API();
|
||||
export let api = new API();
|
||||
|
||||
// 初始化 API
|
||||
export const initializeApi = async () => {
|
||||
const settings = await SettingsManager.get();
|
||||
moonTVApi.setBaseUrl(settings.apiBaseUrl);
|
||||
api.setBaseUrl(settings.apiBaseUrl);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ const resolutionCache: { [url: string]: CacheEntry } = {};
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export const getResolutionFromM3U8 = async (
|
||||
url: string
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> => {
|
||||
// 1. Check cache first
|
||||
const cachedEntry = resolutionCache[url];
|
||||
@@ -20,7 +21,7 @@ export const getResolutionFromM3U8 = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user