mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-06-10 23:33:12 +08:00
feat: implement user authentication and logout functionality
- Added login/logout buttons to the HomeScreen and SettingsScreen. - Integrated authentication state management using Zustand and cookies. - Updated API to support username and password for login. - Enhanced PlayScreen to handle video playback based on user authentication. - Created a new detailStore to manage video details and sources. - Refactored playerStore to utilize detailStore for episode management. - Added sourceStore to manage video source toggling. - Updated settingsStore to fetch server configuration. - Improved error handling and user feedback with Toast notifications. - Cleaned up unused code and optimized imports across components.
This commit is contained in:
@@ -9,6 +9,7 @@ import Toast from "react-native-toast-message";
|
|||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||||
import LoginModal from "@/components/LoginModal";
|
import LoginModal from "@/components/LoginModal";
|
||||||
|
import useAuthStore from "@/stores/authStore";
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
@@ -20,10 +21,13 @@ export default function RootLayout() {
|
|||||||
});
|
});
|
||||||
const { loadSettings, remoteInputEnabled } = useSettingsStore();
|
const { loadSettings, remoteInputEnabled } = useSettingsStore();
|
||||||
const { startServer, stopServer } = useRemoteControlStore();
|
const { startServer, stopServer } = useRemoteControlStore();
|
||||||
|
const { checkLoginStatus } = useAuthStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings().then(() => {
|
||||||
}, [loadSettings]);
|
checkLoginStatus();
|
||||||
|
});
|
||||||
|
}, [loadSettings, checkLoginStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded || error) {
|
if (loaded || error) {
|
||||||
|
|||||||
125
app/detail.tsx
125
app/detail.tsx
@@ -1,134 +1,37 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native";
|
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { api, SearchResult } from "@/services/api";
|
|
||||||
import { getResolutionFromM3U8 } from "@/services/m3u8";
|
|
||||||
import { StyledButton } from "@/components/StyledButton";
|
import { StyledButton } from "@/components/StyledButton";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import useDetailStore from "@/stores/detailStore";
|
||||||
|
|
||||||
export default function DetailScreen() {
|
export default function DetailScreen() {
|
||||||
const { source, q } = useLocalSearchParams();
|
const { q } = useLocalSearchParams<{ q: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [searchResults, setSearchResults] = useState<(SearchResult & { resolution?: string | null })[]>([]);
|
|
||||||
const [detail, setDetail] = useState<(SearchResult & { resolution?: string | null }) | null>(null);
|
const { detail, searchResults, loading, error, allSourcesLoaded, init, setDetail, abort } = useDetailStore();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [allSourcesLoaded, setAllSourcesLoaded] = useState(false);
|
|
||||||
const controllerRef = useRef<AbortController | null>(null);
|
|
||||||
const { videoSource } = useSettingsStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (controllerRef.current) {
|
if (q) {
|
||||||
controllerRef.current.abort();
|
init(q);
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const allResources = await api.getResources(signal);
|
|
||||||
if (!allResources || allResources.length === 0) {
|
|
||||||
setError("没有可用的播放源");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter resources based on enabled sources in settings
|
|
||||||
const resources = videoSource.enabledAll
|
|
||||||
? allResources
|
|
||||||
: allResources.filter((resource) => videoSource.sources[resource.key]);
|
|
||||||
|
|
||||||
if (!videoSource.enabledAll && 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
controllerRef.current?.abort();
|
abort();
|
||||||
};
|
};
|
||||||
}, [q, source, videoSource.enabledAll, videoSource.sources]);
|
}, [abort, init, q]);
|
||||||
|
|
||||||
const handlePlay = (episodeName: string, episodeIndex: number) => {
|
const handlePlay = (episodeIndex: number) => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
controllerRef.current?.abort(); // Cancel any ongoing fetches
|
abort(); // Cancel any ongoing fetches
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/play",
|
pathname: "/play",
|
||||||
params: {
|
params: {
|
||||||
|
// Pass necessary identifiers, the rest will be in the store
|
||||||
|
q: detail.title,
|
||||||
source: detail.source,
|
source: detail.source,
|
||||||
id: detail.id.toString(),
|
id: detail.id.toString(),
|
||||||
episodeUrl: episodeName, // The "episode" is actually the URL
|
|
||||||
episodeIndex: episodeIndex.toString(),
|
episodeIndex: episodeIndex.toString(),
|
||||||
title: detail.title,
|
|
||||||
poster: detail.poster,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -217,7 +120,7 @@ export default function DetailScreen() {
|
|||||||
<StyledButton
|
<StyledButton
|
||||||
key={index}
|
key={index}
|
||||||
style={styles.episodeButton}
|
style={styles.episodeButton}
|
||||||
onPress={() => handlePlay(episode, index)}
|
onPress={() => handlePlay(index)}
|
||||||
text={`第 ${index + 1} 集`}
|
text={`第 ${index + 1} 集`}
|
||||||
textStyle={styles.episodeButtonText}
|
textStyle={styles.episodeButtonText}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { ThemedText } from "@/components/ThemedText";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import VideoCard from "@/components/VideoCard.tv";
|
import VideoCard from "@/components/VideoCard.tv";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { Search, Settings } from "lucide-react-native";
|
import { Search, Settings, LogOut } from "lucide-react-native";
|
||||||
import { StyledButton } from "@/components/StyledButton";
|
import { StyledButton } from "@/components/StyledButton";
|
||||||
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
|
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
|
||||||
|
import useAuthStore from "@/stores/authStore";
|
||||||
|
|
||||||
const NUM_COLUMNS = 5;
|
const NUM_COLUMNS = 5;
|
||||||
const { width } = Dimensions.get("window");
|
const { width } = Dimensions.get("window");
|
||||||
@@ -31,6 +32,7 @@ export default function HomeScreen() {
|
|||||||
selectCategory,
|
selectCategory,
|
||||||
refreshPlayRecords,
|
refreshPlayRecords,
|
||||||
} = useHomeStore();
|
} = useHomeStore();
|
||||||
|
const { isLoggedIn, logout } = useAuthStore();
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -132,6 +134,11 @@ export default function HomeScreen() {
|
|||||||
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
|
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
|
||||||
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
{isLoggedIn && (
|
||||||
|
<StyledButton style={styles.searchButton} onPress={logout} variant="ghost">
|
||||||
|
<LogOut color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||||
|
</StyledButton>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
57
app/play.tsx
57
app/play.tsx
@@ -10,50 +10,51 @@ import { SourceSelectionModal } from "@/components/SourceSelectionModal";
|
|||||||
import { SeekingBar } from "@/components/SeekingBar";
|
import { SeekingBar } from "@/components/SeekingBar";
|
||||||
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
|
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
|
||||||
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
||||||
import usePlayerStore from "@/stores/playerStore";
|
import useDetailStore from "@/stores/detailStore";
|
||||||
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
|
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
|
import usePlayerStore from "@/stores/playerStore";
|
||||||
|
|
||||||
export default function PlayScreen() {
|
export default function PlayScreen() {
|
||||||
const videoRef = useRef<Video>(null);
|
const videoRef = useRef<Video>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
const { source, id, episodeIndex, position } = useLocalSearchParams<{
|
const { episodeIndex: episodeIndexStr } = useLocalSearchParams<{
|
||||||
source: string;
|
|
||||||
id: string;
|
|
||||||
episodeIndex: string;
|
episodeIndex: string;
|
||||||
position: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
const episodeIndex = parseInt(episodeIndexStr || "0", 10);
|
||||||
|
|
||||||
|
const { detail } = useDetailStore();
|
||||||
const {
|
const {
|
||||||
detail,
|
status,
|
||||||
episodes,
|
|
||||||
currentEpisodeIndex,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
showControls,
|
showControls,
|
||||||
showEpisodeModal,
|
|
||||||
showSourceModal,
|
|
||||||
showNextEpisodeOverlay,
|
showNextEpisodeOverlay,
|
||||||
initialPosition,
|
initialPosition,
|
||||||
introEndTime,
|
introEndTime,
|
||||||
setVideoRef,
|
setVideoRef,
|
||||||
loadVideo,
|
|
||||||
handlePlaybackStatusUpdate,
|
handlePlaybackStatusUpdate,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
setShowEpisodeModal,
|
|
||||||
setShowSourceModal,
|
|
||||||
setShowNextEpisodeOverlay,
|
setShowNextEpisodeOverlay,
|
||||||
reset,
|
reset,
|
||||||
|
playEpisode,
|
||||||
|
togglePlayPause,
|
||||||
|
seek,
|
||||||
} = usePlayerStore();
|
} = usePlayerStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVideoRef(videoRef);
|
setVideoRef(videoRef);
|
||||||
if (source && id) {
|
// The detail is already in the detailStore, no need to load it again.
|
||||||
loadVideo(source, id, parseInt(episodeIndex || "0", 10), parseInt(position || "0", 10));
|
// We just need to set the current episode in the player store.
|
||||||
}
|
usePlayerStore.setState({
|
||||||
|
currentEpisodeIndex: episodeIndex,
|
||||||
|
episodes: detail?.episodes.map((ep, index) => ({ url: ep, title: `第 ${index + 1} 集` })) || [],
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
reset(); // Reset state when component unmounts
|
reset(); // Reset state when component unmounts
|
||||||
};
|
};
|
||||||
}, [source, id, episodeIndex, position, setVideoRef, loadVideo, reset]);
|
}, [episodeIndex, detail, setVideoRef, reset]);
|
||||||
|
|
||||||
const { onScreenPress } = useTVRemoteHandler();
|
const { onScreenPress } = useTVRemoteHandler();
|
||||||
|
|
||||||
@@ -70,17 +71,9 @@ export default function PlayScreen() {
|
|||||||
const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction);
|
const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction);
|
||||||
|
|
||||||
return () => backHandler.remove();
|
return () => backHandler.remove();
|
||||||
}, [
|
}, [showControls, setShowControls, router]);
|
||||||
showControls,
|
|
||||||
showEpisodeModal,
|
|
||||||
showSourceModal,
|
|
||||||
setShowControls,
|
|
||||||
setShowEpisodeModal,
|
|
||||||
setShowSourceModal,
|
|
||||||
router,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!detail && isLoading) {
|
if (!detail) {
|
||||||
return (
|
return (
|
||||||
<ThemedView style={[styles.container, styles.centered]}>
|
<ThemedView style={[styles.container, styles.centered]}>
|
||||||
<ActivityIndicator size="large" color="#fff" />
|
<ActivityIndicator size="large" color="#fff" />
|
||||||
@@ -88,7 +81,7 @@ export default function PlayScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentEpisode = episodes[currentEpisodeIndex];
|
const currentEpisode = detail.episodes[episodeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView focusable style={styles.container}>
|
<ThemedView focusable style={styles.container}>
|
||||||
@@ -96,9 +89,9 @@ export default function PlayScreen() {
|
|||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
style={styles.videoPlayer}
|
style={styles.videoPlayer}
|
||||||
source={{ uri: currentEpisode?.url }}
|
source={{ uri: currentEpisode }}
|
||||||
usePoster
|
usePoster
|
||||||
posterSource={{ uri: detail?.videoInfo.poster ?? "" }}
|
posterSource={{ uri: detail?.poster ?? "" }}
|
||||||
resizeMode={ResizeMode.CONTAIN}
|
resizeMode={ResizeMode.CONTAIN}
|
||||||
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
@@ -108,6 +101,10 @@ export default function PlayScreen() {
|
|||||||
}
|
}
|
||||||
usePlayerStore.setState({ isLoading: false });
|
usePlayerStore.setState({ isLoading: false });
|
||||||
}}
|
}}
|
||||||
|
onError={() => {
|
||||||
|
usePlayerStore.setState({ isLoading: false });
|
||||||
|
Toast.show({ type: "error", text1: "播放失败,请更换源后重试" });
|
||||||
|
}}
|
||||||
onLoadStart={() => usePlayerStore.setState({ isLoading: true })}
|
onLoadStart={() => usePlayerStore.setState({ isLoading: true })}
|
||||||
useNativeControls={false}
|
useNativeControls={false}
|
||||||
shouldPlay
|
shouldPlay
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ThemedView } from "@/components/ThemedView";
|
|||||||
import { StyledButton } from "@/components/StyledButton";
|
import { StyledButton } from "@/components/StyledButton";
|
||||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
import useAuthStore from "@/stores/authStore";
|
||||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||||
import { APIConfigSection } from "@/components/settings/APIConfigSection";
|
import { APIConfigSection } from "@/components/settings/APIConfigSection";
|
||||||
import { LiveStreamSection } from "@/components/settings/LiveStreamSection";
|
import { LiveStreamSection } from "@/components/settings/LiveStreamSection";
|
||||||
@@ -16,6 +17,7 @@ import Toast from "react-native-toast-message";
|
|||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
|
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
|
||||||
const { lastMessage } = useRemoteControlStore();
|
const { lastMessage } = useRemoteControlStore();
|
||||||
|
const { isLoggedIn, logout } = useAuthStore();
|
||||||
const backgroundColor = useThemeColor({}, "background");
|
const backgroundColor = useThemeColor({}, "background");
|
||||||
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
"api_site": {
|
"api_site": {
|
||||||
"dyttzy": {
|
"dyttzy": {
|
||||||
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
|
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
|
||||||
"name": "电影天堂资源",
|
"name": "电影天堂资源"
|
||||||
"detail": "http://caiji.dyttzyapi.com"
|
|
||||||
},
|
},
|
||||||
"ruyi": {
|
"ruyi": {
|
||||||
"api": "https://cj.rycjapi.com/api.php/provide/vod",
|
"api": "https://cj.rycjapi.com/api.php/provide/vod",
|
||||||
@@ -16,8 +15,7 @@
|
|||||||
},
|
},
|
||||||
"heimuer": {
|
"heimuer": {
|
||||||
"api": "https://json.heimuer.xyz/api.php/provide/vod",
|
"api": "https://json.heimuer.xyz/api.php/provide/vod",
|
||||||
"name": "黑木耳",
|
"name": "黑木耳"
|
||||||
"detail": "https://heimuer.tv"
|
|
||||||
},
|
},
|
||||||
"bfzy": {
|
"bfzy": {
|
||||||
"api": "https://bfzyapi.com/api.php/provide/vod",
|
"api": "https://bfzyapi.com/api.php/provide/vod",
|
||||||
@@ -29,8 +27,7 @@
|
|||||||
},
|
},
|
||||||
"ffzy": {
|
"ffzy": {
|
||||||
"api": "http://ffzy5.tv/api.php/provide/vod",
|
"api": "http://ffzy5.tv/api.php/provide/vod",
|
||||||
"name": "非凡影视",
|
"name": "非凡影视"
|
||||||
"detail": "http://ffzy5.tv"
|
|
||||||
},
|
},
|
||||||
"zy360": {
|
"zy360": {
|
||||||
"api": "https://360zy.com/api.php/provide/vod",
|
"api": "https://360zy.com/api.php/provide/vod",
|
||||||
@@ -50,8 +47,7 @@
|
|||||||
},
|
},
|
||||||
"jisu": {
|
"jisu": {
|
||||||
"api": "https://jszyapi.com/api.php/provide/vod",
|
"api": "https://jszyapi.com/api.php/provide/vod",
|
||||||
"name": "极速资源",
|
"name": "极速资源"
|
||||||
"detail": "https://jszyapi.com"
|
|
||||||
},
|
},
|
||||||
"dbzy": {
|
"dbzy": {
|
||||||
"api": "https://dbzy.tv/api.php/provide/vod",
|
"api": "https://dbzy.tv/api.php/provide/vod",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{}
|
{}
|
||||||
@@ -4,6 +4,7 @@ import dotenv from "dotenv";
|
|||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const username = process.env.USERNAME;
|
||||||
const password = process.env.PASSWORD;
|
const password = process.env.PASSWORD;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +12,7 @@ const password = process.env.PASSWORD;
|
|||||||
* @apiName UserLogin
|
* @apiName UserLogin
|
||||||
* @apiGroup User
|
* @apiGroup User
|
||||||
*
|
*
|
||||||
|
* @apiBody {String} username User's username.
|
||||||
* @apiBody {String} password User's password.
|
* @apiBody {String} password User's password.
|
||||||
*
|
*
|
||||||
* @apiSuccess {Boolean} ok Indicates if the login was successful.
|
* @apiSuccess {Boolean} ok Indicates if the login was successful.
|
||||||
@@ -30,17 +32,26 @@ const password = process.env.PASSWORD;
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
router.post("/login", (req: Request, res: Response) => {
|
router.post("/login", (req: Request, res: Response) => {
|
||||||
if (!password) {
|
const { username: inputUsername, password: inputPassword } = req.body;
|
||||||
// If no password is set, login is always successful.
|
|
||||||
return res.json({ ok: true });
|
// Compatibility with old versions, if username is not set, only password is required
|
||||||
|
if (!username || !password) {
|
||||||
|
if (inputPassword === password) {
|
||||||
|
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
|
||||||
|
return res.json({ ok: true });
|
||||||
|
} else if (!password) {
|
||||||
|
// If no password is set, login is always successful.
|
||||||
|
return res.json({ ok: true });
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ message: "Invalid password" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { password: inputPassword } = req.body;
|
if (inputUsername === username && inputPassword === password) {
|
||||||
|
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
|
||||||
if (inputPassword === password) {
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ message: "Invalid password" });
|
res.status(400).json({ message: "Invalid username or password" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,38 @@ import React, { useState } from "react";
|
|||||||
import { Modal, View, Text, TextInput, StyleSheet, ActivityIndicator } from "react-native";
|
import { Modal, View, Text, TextInput, StyleSheet, ActivityIndicator } from "react-native";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import useAuthStore from "@/stores/authStore";
|
import useAuthStore from "@/stores/authStore";
|
||||||
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
import useHomeStore from "@/stores/homeStore";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ThemedView } from "./ThemedView";
|
import { ThemedView } from "./ThemedView";
|
||||||
import { ThemedText } from "./ThemedText";
|
import { ThemedText } from "./ThemedText";
|
||||||
import { StyledButton } from "./StyledButton";
|
import { StyledButton } from "./StyledButton";
|
||||||
|
|
||||||
const LoginModal = () => {
|
const LoginModal = () => {
|
||||||
const { isLoginModalVisible, hideLoginModal } = useAuthStore();
|
const { isLoginModalVisible, hideLoginModal, checkLoginStatus } = useAuthStore();
|
||||||
|
const { serverConfig } = useSettingsStore();
|
||||||
|
const { refreshPlayRecords } = useHomeStore();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!password) {
|
const isLocalStorage = serverConfig?.StorageType === "localstorage";
|
||||||
Toast.show({ type: "error", text1: "请输入密码" });
|
if (!password || (!isLocalStorage && !username)) {
|
||||||
|
Toast.show({ type: "error", text1: "请输入用户名和密码" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.login(password);
|
await api.login(isLocalStorage ? undefined : username, password);
|
||||||
|
await checkLoginStatus();
|
||||||
|
await refreshPlayRecords();
|
||||||
Toast.show({ type: "success", text1: "登录成功" });
|
Toast.show({ type: "success", text1: "登录成功" });
|
||||||
hideLoginModal();
|
hideLoginModal();
|
||||||
|
setUsername("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.show({ type: "error", text1: "登录失败", text2: "密码错误或服务器无法连接" });
|
Toast.show({ type: "error", text1: "登录失败", text2: "用户名或密码错误" });
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -36,6 +45,16 @@ const LoginModal = () => {
|
|||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText style={styles.title}>需要登录</ThemedText>
|
<ThemedText style={styles.title}>需要登录</ThemedText>
|
||||||
<ThemedText style={styles.subtitle}>服务器需要验证您的身份</ThemedText>
|
<ThemedText style={styles.subtitle}>服务器需要验证您的身份</ThemedText>
|
||||||
|
{serverConfig?.StorageType !== "localstorage" && (
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
placeholderTextColor="#888"
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
@@ -43,7 +62,6 @@ const LoginModal = () => {
|
|||||||
secureTextEntry
|
secureTextEntry
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<StyledButton text={isLoading ? "" : "登录"} onPress={handleLogin} disabled={isLoading} style={styles.button}>
|
<StyledButton text={isLoading ? "" : "登录"} onPress={handleLogin} disabled={isLoading} style={styles.button}>
|
||||||
{isLoading && <ActivityIndicator color="#fff" />}
|
{isLoading && <ActivityIndicator color="#fff" />}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ThemedText } from "@/components/ThemedText";
|
|||||||
import { MediaButton } from "@/components/MediaButton";
|
import { MediaButton } from "@/components/MediaButton";
|
||||||
|
|
||||||
import usePlayerStore from "@/stores/playerStore";
|
import usePlayerStore from "@/stores/playerStore";
|
||||||
|
import useDetailStore from "@/stores/detailStore";
|
||||||
|
import { useSources } from "@/stores/sourceStore";
|
||||||
|
|
||||||
interface PlayerControlsProps {
|
interface PlayerControlsProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -13,9 +15,8 @@ interface PlayerControlsProps {
|
|||||||
|
|
||||||
export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => {
|
export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => {
|
||||||
const {
|
const {
|
||||||
detail,
|
|
||||||
currentEpisodeIndex,
|
currentEpisodeIndex,
|
||||||
currentSourceIndex,
|
episodes,
|
||||||
status,
|
status,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
seekPosition,
|
seekPosition,
|
||||||
@@ -30,12 +31,15 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
|
|||||||
outroStartTime,
|
outroStartTime,
|
||||||
} = usePlayerStore();
|
} = usePlayerStore();
|
||||||
|
|
||||||
const videoTitle = detail?.videoInfo?.title || "";
|
const { detail } = useDetailStore();
|
||||||
const currentEpisode = detail?.episodes[currentEpisodeIndex];
|
const resources = useSources();
|
||||||
|
|
||||||
|
const videoTitle = detail?.title || "";
|
||||||
|
const currentEpisode = episodes[currentEpisodeIndex];
|
||||||
const currentEpisodeTitle = currentEpisode?.title;
|
const currentEpisodeTitle = currentEpisode?.title;
|
||||||
const currentSource = detail?.sources[currentSourceIndex];
|
const currentSource = resources.find((r) => r.source === detail?.source);
|
||||||
const currentSourceName = currentSource?.source_name;
|
const currentSourceName = currentSource?.source_name;
|
||||||
const hasNextEpisode = currentEpisodeIndex < (detail?.episodes.length || 0) - 1;
|
const hasNextEpisode = currentEpisodeIndex < (episodes.length || 0) - 1;
|
||||||
|
|
||||||
const formatTime = (milliseconds: number) => {
|
const formatTime = (milliseconds: number) => {
|
||||||
if (!milliseconds) return "00:00";
|
if (!milliseconds) return "00:00";
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
|
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
|
||||||
import { StyledButton } from "./StyledButton";
|
import { StyledButton } from "./StyledButton";
|
||||||
|
import useDetailStore from "@/stores/detailStore";
|
||||||
import usePlayerStore from "@/stores/playerStore";
|
import usePlayerStore from "@/stores/playerStore";
|
||||||
|
|
||||||
export const SourceSelectionModal: React.FC = () => {
|
export const SourceSelectionModal: React.FC = () => {
|
||||||
const { showSourceModal, sources, currentSourceIndex, switchSource, setShowSourceModal } = usePlayerStore();
|
const { showSourceModal, setShowSourceModal } = usePlayerStore();
|
||||||
|
const { searchResults, detail, setDetail } = useDetailStore();
|
||||||
|
|
||||||
const onSelectSource = (index: number) => {
|
const onSelectSource = (index: number) => {
|
||||||
if (index !== currentSourceIndex) {
|
if (searchResults[index].source !== detail?.source) {
|
||||||
switchSource(index);
|
setDetail(searchResults[index]);
|
||||||
}
|
}
|
||||||
setShowSourceModal(false);
|
setShowSourceModal(false);
|
||||||
};
|
};
|
||||||
@@ -23,16 +25,16 @@ export const SourceSelectionModal: React.FC = () => {
|
|||||||
<View style={styles.modalContent}>
|
<View style={styles.modalContent}>
|
||||||
<Text style={styles.modalTitle}>选择播放源</Text>
|
<Text style={styles.modalTitle}>选择播放源</Text>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={sources}
|
data={searchResults}
|
||||||
numColumns={3}
|
numColumns={3}
|
||||||
contentContainerStyle={styles.sourceList}
|
contentContainerStyle={styles.sourceList}
|
||||||
keyExtractor={(item, index) => `source-${item.source}-${item.id}-${index}`}
|
keyExtractor={(item, index) => `source-${item.source}-${index}`}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
text={item.source_name}
|
text={item.source_name}
|
||||||
onPress={() => onSelectSource(index)}
|
onPress={() => onSelectSource(index)}
|
||||||
isSelected={currentSourceIndex === index}
|
isSelected={detail?.source === item.source}
|
||||||
hasTVPreferredFocus={currentSourceIndex === index}
|
hasTVPreferredFocus={detail?.source === item.source}
|
||||||
style={styles.sourceItem}
|
style={styles.sourceItem}
|
||||||
textStyle={styles.sourceItemText}
|
textStyle={styles.sourceItemText}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { StyleSheet, View, Switch, ActivityIndicator, FlatList, Pressable, Animated } from "react-native";
|
import { StyleSheet, Switch, FlatList, Pressable, Animated } from "react-native";
|
||||||
import { useTVEventHandler } from "react-native";
|
import { useTVEventHandler } from "react-native";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { SettingsSection } from "./SettingsSection";
|
import { SettingsSection } from "./SettingsSection";
|
||||||
import { api, ApiSite } from "@/services/api";
|
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
import useSourceStore, { useSources } from "@/stores/sourceStore";
|
||||||
|
|
||||||
interface VideoSourceSectionProps {
|
interface VideoSourceSectionProps {
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
@@ -13,56 +13,18 @@ interface VideoSourceSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChanged, onFocus, onBlur }) => {
|
export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChanged, onFocus, onBlur }) => {
|
||||||
const [resources, setResources] = useState<ApiSite[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
||||||
const { videoSource, setVideoSource } = useSettingsStore();
|
const { videoSource } = useSettingsStore();
|
||||||
|
const resources = useSources();
|
||||||
|
const { toggleResourceEnabled } = useSourceStore();
|
||||||
|
|
||||||
useEffect(() => {
|
const handleToggle = useCallback(
|
||||||
fetchResources();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchResources = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const resourcesList = await api.getResources();
|
|
||||||
setResources(resourcesList);
|
|
||||||
|
|
||||||
if (videoSource.enabledAll && Object.keys(videoSource.sources).length === 0) {
|
|
||||||
const allResourceKeys: { [key: string]: boolean } = {};
|
|
||||||
for (const resource of resourcesList) {
|
|
||||||
allResourceKeys[resource.key] = true;
|
|
||||||
}
|
|
||||||
setVideoSource({
|
|
||||||
enabledAll: true,
|
|
||||||
sources: allResourceKeys,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError("获取播放源失败");
|
|
||||||
console.error("Failed to fetch resources:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleResourceEnabled = useCallback(
|
|
||||||
(resourceKey: string) => {
|
(resourceKey: string) => {
|
||||||
const isEnabled = videoSource.sources[resourceKey];
|
toggleResourceEnabled(resourceKey);
|
||||||
|
|
||||||
const newEnabledSources = { ...videoSource.sources, [resourceKey]: !isEnabled };
|
|
||||||
|
|
||||||
setVideoSource({
|
|
||||||
enabledAll: Object.values(newEnabledSources).every((enabled) => enabled),
|
|
||||||
sources: newEnabledSources,
|
|
||||||
});
|
|
||||||
|
|
||||||
onChanged();
|
onChanged();
|
||||||
},
|
},
|
||||||
[videoSource.sources, setVideoSource, onChanged]
|
[onChanged, toggleResourceEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSectionFocus = () => {
|
const handleSectionFocus = () => {
|
||||||
@@ -83,20 +45,20 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
|
|||||||
if (focusedIndex !== null) {
|
if (focusedIndex !== null) {
|
||||||
const resource = resources[focusedIndex];
|
const resource = resources[focusedIndex];
|
||||||
if (resource) {
|
if (resource) {
|
||||||
toggleResourceEnabled(resource.key);
|
handleToggle(resource.source);
|
||||||
}
|
}
|
||||||
} else if (isSectionFocused) {
|
} else if (isSectionFocused) {
|
||||||
setFocusedIndex(0);
|
setFocusedIndex(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isSectionFocused, focusedIndex, resources, toggleResourceEnabled]
|
[isSectionFocused, focusedIndex, resources, handleToggle]
|
||||||
);
|
);
|
||||||
|
|
||||||
useTVEventHandler(handleTVEvent);
|
useTVEventHandler(handleTVEvent);
|
||||||
|
|
||||||
const renderResourceItem = ({ item, index }: { item: ApiSite; index: number }) => {
|
const renderResourceItem = ({ item, index }: { item: { source: string; source_name: string }; index: number }) => {
|
||||||
const isEnabled = videoSource.enabledAll || videoSource.sources[item.key];
|
const isEnabled = videoSource.enabledAll || videoSource.sources[item.source];
|
||||||
const isFocused = focusedIndex === index;
|
const isFocused = focusedIndex === index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,7 +69,7 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
|
|||||||
onFocus={() => setFocusedIndex(index)}
|
onFocus={() => setFocusedIndex(index)}
|
||||||
onBlur={() => setFocusedIndex(null)}
|
onBlur={() => setFocusedIndex(null)}
|
||||||
>
|
>
|
||||||
<ThemedText style={styles.resourceName}>{item.name}</ThemedText>
|
<ThemedText style={styles.resourceName}>{item.source_name}</ThemedText>
|
||||||
<Switch
|
<Switch
|
||||||
value={isEnabled}
|
value={isEnabled}
|
||||||
onValueChange={() => {}} // 禁用Switch的直接交互
|
onValueChange={() => {}} // 禁用Switch的直接交互
|
||||||
@@ -124,20 +86,11 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
|
|||||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||||
<ThemedText style={styles.sectionTitle}>播放源配置</ThemedText>
|
<ThemedText style={styles.sectionTitle}>播放源配置</ThemedText>
|
||||||
|
|
||||||
{loading && (
|
{resources.length > 0 && (
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator size="small" />
|
|
||||||
<ThemedText style={styles.loadingText}>加载中...</ThemedText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <ThemedText style={styles.errorText}>{error}</ThemedText>}
|
|
||||||
|
|
||||||
{!loading && !error && resources.length > 0 && (
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={resources}
|
data={resources}
|
||||||
renderItem={renderResourceItem}
|
renderItem={renderResourceItem}
|
||||||
keyExtractor={(item) => item.key}
|
keyExtractor={(item) => item.source}
|
||||||
numColumns={3}
|
numColumns={3}
|
||||||
columnWrapperStyle={styles.row}
|
columnWrapperStyle={styles.row}
|
||||||
contentContainerStyle={styles.flatListContainer}
|
contentContainerStyle={styles.flatListContainer}
|
||||||
@@ -154,22 +107,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
loadingText: {
|
|
||||||
marginLeft: 8,
|
|
||||||
color: "#888",
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: "#ff4444",
|
|
||||||
fontSize: 14,
|
|
||||||
textAlign: "center",
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
flatListContainer: {
|
flatListContainer: {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@expo/vector-icons": "^14.0.0",
|
"@expo/vector-icons": "^14.0.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-native-community/netinfo": "^11.3.2",
|
"@react-native-community/netinfo": "^11.3.2",
|
||||||
|
"@react-native-cookies/cookies": "^6.2.1",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
"expo": "~51.0.13",
|
"expo": "~51.0.13",
|
||||||
"expo-av": "~14.0.7",
|
"expo-av": "~14.0.7",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { SettingsManager } from "./storage";
|
|
||||||
import useAuthStore from "@/stores/authStore";
|
import useAuthStore from "@/stores/authStore";
|
||||||
|
|
||||||
// region: --- Interface Definitions ---
|
// region: --- Interface Definitions ---
|
||||||
@@ -52,7 +51,7 @@ export interface Favorite {
|
|||||||
export interface PlayRecord {
|
export interface PlayRecord {
|
||||||
title: string;
|
title: string;
|
||||||
source_name: string;
|
source_name: string;
|
||||||
poster: string;
|
cover: string;
|
||||||
index: number;
|
index: number;
|
||||||
total_episodes: number;
|
total_episodes: number;
|
||||||
play_time: number;
|
play_time: number;
|
||||||
@@ -71,7 +70,6 @@ export interface ServerConfig {
|
|||||||
SiteName: string;
|
SiteName: string;
|
||||||
StorageType: "localstorage" | "redis" | string;
|
StorageType: "localstorage" | "redis" | string;
|
||||||
}
|
}
|
||||||
// endregion
|
|
||||||
|
|
||||||
export class API {
|
export class API {
|
||||||
public baseURL: string = "";
|
public baseURL: string = "";
|
||||||
@@ -105,17 +103,16 @@ export class API {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// region: --- New API Methods ---
|
|
||||||
async getServerConfig(): Promise<ServerConfig> {
|
async getServerConfig(): Promise<ServerConfig> {
|
||||||
const response = await this._fetch("/api/server-config");
|
const response = await this._fetch("/api/server-config");
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(password: string): Promise<{ ok: boolean }> {
|
async login(username: string | undefined, password: string): Promise<{ ok: boolean }> {
|
||||||
const response = await this._fetch("/api/login", {
|
const response = await this._fetch("/api/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
@@ -180,9 +177,7 @@ export class API {
|
|||||||
const response = await this._fetch(url, { method: "DELETE" });
|
const response = await this._fetch(url, { method: "DELETE" });
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region: --- Existing API Methods (Refactored) ---
|
|
||||||
getImageProxyUrl(imageUrl: string): string {
|
getImageProxyUrl(imageUrl: string): string {
|
||||||
return `${this.baseURL}/api/image-proxy?url=${encodeURIComponent(imageUrl)}`;
|
return `${this.baseURL}/api/image-proxy?url=${encodeURIComponent(imageUrl)}`;
|
||||||
}
|
}
|
||||||
@@ -221,14 +216,7 @@ export class API {
|
|||||||
const response = await this._fetch(url);
|
const response = await this._fetch(url);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
// endregion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认实例
|
// 默认实例
|
||||||
export let api = new API();
|
export let api = new API();
|
||||||
|
|
||||||
// 初始化 API
|
|
||||||
export const initializeApi = async () => {
|
|
||||||
const settings = await SettingsManager.get();
|
|
||||||
api.setBaseUrl(settings.apiBaseUrl);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import Cookies from "@react-native-cookies/cookies";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
isLoggedIn: boolean;
|
||||||
isLoginModalVisible: boolean;
|
isLoginModalVisible: boolean;
|
||||||
showLoginModal: () => void;
|
showLoginModal: () => void;
|
||||||
hideLoginModal: () => void;
|
hideLoginModal: () => void;
|
||||||
|
checkLoginStatus: () => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useAuthStore = create<AuthState>((set) => ({
|
const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
isLoggedIn: false,
|
||||||
isLoginModalVisible: false,
|
isLoginModalVisible: false,
|
||||||
showLoginModal: () => set({ isLoginModalVisible: true }),
|
showLoginModal: () => set({ isLoginModalVisible: true }),
|
||||||
hideLoginModal: () => set({ isLoginModalVisible: false }),
|
hideLoginModal: () => set({ isLoginModalVisible: false }),
|
||||||
|
checkLoginStatus: async () => {
|
||||||
|
try {
|
||||||
|
const cookies = await Cookies.get(api.baseURL);
|
||||||
|
const isLoggedIn = cookies && !!cookies.auth;
|
||||||
|
set({ isLoggedIn });
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
set({ isLoginModalVisible: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check login status:", error);
|
||||||
|
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await Cookies.clearAll();
|
||||||
|
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to logout:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useAuthStore;
|
export default useAuthStore;
|
||||||
|
|||||||
141
stores/detailStore.ts
Normal file
141
stores/detailStore.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { SearchResult, api } from "@/services/api";
|
||||||
|
import { getResolutionFromM3U8 } from "@/services/m3u8";
|
||||||
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
|
||||||
|
export type SearchResultWithResolution = SearchResult & { resolution?: string | null };
|
||||||
|
|
||||||
|
interface DetailState {
|
||||||
|
q: string | null;
|
||||||
|
searchResults: SearchResultWithResolution[];
|
||||||
|
sources: { source: string; source_name: string; resolution: string | null | undefined }[];
|
||||||
|
detail: SearchResultWithResolution | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
allSourcesLoaded: boolean;
|
||||||
|
controller: AbortController | null
|
||||||
|
|
||||||
|
init: (q: string) => void;
|
||||||
|
setDetail: (detail: SearchResultWithResolution) => void;
|
||||||
|
abort: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDetailStore = create<DetailState>((set, get) => ({
|
||||||
|
q: null,
|
||||||
|
searchResults: [],
|
||||||
|
sources: [],
|
||||||
|
detail: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
allSourcesLoaded: false,
|
||||||
|
controller: null,
|
||||||
|
|
||||||
|
init: async (q) => {
|
||||||
|
const { controller: oldController } = get();
|
||||||
|
if (oldController) {
|
||||||
|
oldController.abort();
|
||||||
|
}
|
||||||
|
const newController = new AbortController();
|
||||||
|
const signal = newController.signal;
|
||||||
|
|
||||||
|
set({
|
||||||
|
q,
|
||||||
|
loading: true,
|
||||||
|
searchResults: [],
|
||||||
|
detail: null,
|
||||||
|
error: null,
|
||||||
|
allSourcesLoaded: false,
|
||||||
|
controller: newController,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { videoSource } = useSettingsStore.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processAndSetResults = async (
|
||||||
|
results: SearchResult[]
|
||||||
|
) => {
|
||||||
|
const resultsWithResolution = await Promise.all(
|
||||||
|
results.map(async (searchResult) => {
|
||||||
|
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 ${searchResult.source_name}`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...searchResult, resolution };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const existingSources = new Set(state.searchResults.map((r) => r.source));
|
||||||
|
const newResults = resultsWithResolution.filter(
|
||||||
|
(r) => !existingSources.has(r.source)
|
||||||
|
);
|
||||||
|
const finalResults = [...state.searchResults, ...newResults];
|
||||||
|
return {
|
||||||
|
searchResults: finalResults,
|
||||||
|
sources: finalResults.map((r) => ({
|
||||||
|
source: r.source,
|
||||||
|
source_name: r.source_name,
|
||||||
|
resolution: r.resolution,
|
||||||
|
})),
|
||||||
|
detail: state.detail ?? finalResults[0] ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Background fetch for all sources
|
||||||
|
const { results: allResults } = await api.searchVideos(q);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
const filteredResults = videoSource.enabledAll
|
||||||
|
? allResults
|
||||||
|
: allResults.filter((result) => videoSource.sources[result.source]);
|
||||||
|
|
||||||
|
if (filteredResults.length > 0) {
|
||||||
|
await processAndSetResults(filteredResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get().searchResults.length === 0) {
|
||||||
|
if (!videoSource.enabledAll) {
|
||||||
|
set({ error: "请到设置页面启用的播放源" });
|
||||||
|
} else {
|
||||||
|
set({ error: "未找到播放源" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== "AbortError") {
|
||||||
|
set({ error: e instanceof Error ? e.message : "获取数据失败" });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
set({ loading: false, allSourcesLoaded: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setDetail: (detail) => {
|
||||||
|
set({ detail });
|
||||||
|
},
|
||||||
|
|
||||||
|
abort: () => {
|
||||||
|
get().controller?.abort();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const sourcesSelector = (state: DetailState) => state.sources;
|
||||||
|
export default useDetailStore;
|
||||||
|
export const episodesSelectorBySource = (source: string) => (state: DetailState) =>
|
||||||
|
state.searchResults.find((r) => r.source === source)?.episodes || [];
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { api, SearchResult, PlayRecord } from '@/services/api';
|
import { api, SearchResult, PlayRecord } from '@/services/api';
|
||||||
import { PlayRecordManager } from '@/services/storage';
|
import { PlayRecordManager } from '@/services/storage';
|
||||||
|
import useAuthStore from './authStore';
|
||||||
|
|
||||||
export type RowItem = (SearchResult | PlayRecord) & {
|
export type RowItem = (SearchResult | PlayRecord) & {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -73,6 +74,11 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (selectedCategory.type === 'record') {
|
if (selectedCategory.type === 'record') {
|
||||||
|
const { isLoggedIn } = useAuthStore.getState();
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
set({ contentData: [], hasMore: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const records = await PlayRecordManager.getAll();
|
const records = await PlayRecordManager.getAll();
|
||||||
const rowItems = Object.entries(records)
|
const rowItems = Object.entries(records)
|
||||||
.map(([key, record]) => {
|
.map(([key, record]) => {
|
||||||
@@ -126,6 +132,21 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
refreshPlayRecords: async () => {
|
refreshPlayRecords: async () => {
|
||||||
|
const { isLoggedIn } = useAuthStore.getState();
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
set(state => {
|
||||||
|
const recordCategoryExists = state.categories.some(c => c.type === 'record');
|
||||||
|
if (recordCategoryExists) {
|
||||||
|
const newCategories = state.categories.filter(c => c.type !== 'record');
|
||||||
|
if (state.selectedCategory.type === 'record') {
|
||||||
|
get().selectCategory(newCategories[0] || null);
|
||||||
|
}
|
||||||
|
return { categories: newCategories };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const records = await PlayRecordManager.getAll();
|
const records = await PlayRecordManager.getAll();
|
||||||
const hasRecords = Object.keys(records).length > 0;
|
const hasRecords = Object.keys(records).length > 0;
|
||||||
set(state => {
|
set(state => {
|
||||||
|
|||||||
@@ -2,27 +2,18 @@ import { create } from "zustand";
|
|||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import { AVPlaybackStatus, Video } from "expo-av";
|
import { AVPlaybackStatus, Video } from "expo-av";
|
||||||
import { RefObject } from "react";
|
import { RefObject } from "react";
|
||||||
import { api, VideoDetail as ApiVideoDetail, SearchResult } from "@/services/api";
|
|
||||||
import { PlayRecord, PlayRecordManager } from "@/services/storage";
|
import { PlayRecord, PlayRecordManager } from "@/services/storage";
|
||||||
|
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
|
||||||
|
|
||||||
interface Episode {
|
interface Episode {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VideoDetail {
|
|
||||||
videoInfo: ApiVideoDetail;
|
|
||||||
episodes: Episode[];
|
|
||||||
sources: SearchResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
videoRef: RefObject<Video> | null;
|
videoRef: RefObject<Video> | null;
|
||||||
detail: VideoDetail | null;
|
|
||||||
episodes: Episode[];
|
|
||||||
sources: SearchResult[];
|
|
||||||
currentSourceIndex: number;
|
|
||||||
currentEpisodeIndex: number;
|
currentEpisodeIndex: number;
|
||||||
|
episodes: Episode[];
|
||||||
status: AVPlaybackStatus | null;
|
status: AVPlaybackStatus | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -36,8 +27,11 @@ interface PlayerState {
|
|||||||
introEndTime?: number;
|
introEndTime?: number;
|
||||||
outroStartTime?: number;
|
outroStartTime?: number;
|
||||||
setVideoRef: (ref: RefObject<Video>) => void;
|
setVideoRef: (ref: RefObject<Video>) => void;
|
||||||
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
|
loadVideo: (
|
||||||
switchSource: (newSourceIndex: number) => Promise<void>;
|
source: string,
|
||||||
|
episodeIndex: number,
|
||||||
|
position?: number
|
||||||
|
) => Promise<void>;
|
||||||
playEpisode: (index: number) => void;
|
playEpisode: (index: number) => void;
|
||||||
togglePlayPause: () => void;
|
togglePlayPause: () => void;
|
||||||
seek: (duration: number) => void;
|
seek: (duration: number) => void;
|
||||||
@@ -57,10 +51,7 @@ interface PlayerState {
|
|||||||
|
|
||||||
const usePlayerStore = create<PlayerState>((set, get) => ({
|
const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||||
videoRef: null,
|
videoRef: null,
|
||||||
detail: null,
|
|
||||||
episodes: [],
|
episodes: [],
|
||||||
sources: [],
|
|
||||||
currentSourceIndex: 0,
|
|
||||||
currentEpisodeIndex: 0,
|
currentEpisodeIndex: 0,
|
||||||
status: null,
|
status: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@@ -78,76 +69,38 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
|
|
||||||
setVideoRef: (ref) => set({ videoRef: ref }),
|
setVideoRef: (ref) => set({ videoRef: ref }),
|
||||||
|
|
||||||
loadVideo: async (source, id, episodeIndex, position) => {
|
loadVideo: async (source, episodeIndex, position) => {
|
||||||
|
const detail = useDetailStore.getState().detail;
|
||||||
|
const episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||||
|
|
||||||
|
if (!detail || !episodes || episodes.length === 0) return;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
detail: null,
|
currentEpisodeIndex: episodeIndex,
|
||||||
episodes: [],
|
|
||||||
sources: [],
|
|
||||||
currentEpisodeIndex: 0,
|
|
||||||
initialPosition: position || 0,
|
initialPosition: position || 0,
|
||||||
|
episodes: episodes.map((ep, index) => ({
|
||||||
|
url: ep,
|
||||||
|
title: `第 ${index + 1} 集`,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
try {
|
|
||||||
const videoDetail = await api.getVideoDetail(source, id);
|
|
||||||
const [{ results: sources }, resources] = await Promise.all([
|
|
||||||
api.searchVideo(videoDetail.title, source),
|
|
||||||
api.getResources(),
|
|
||||||
]);
|
|
||||||
const currentSourceIndex = resources.findIndex((s) => s.key === source);
|
|
||||||
const episodes = sources.map((ep, index) => ({ url: ep.episodes[index], title: `第 ${index + 1} 集` }));
|
|
||||||
const playRecord = await PlayRecordManager.get(source, id);
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playRecord = await PlayRecordManager.get(
|
||||||
|
detail.source,
|
||||||
|
detail.id.toString()
|
||||||
|
);
|
||||||
set({
|
set({
|
||||||
detail: { videoInfo: videoDetail, episodes, sources },
|
|
||||||
episodes,
|
|
||||||
sources,
|
|
||||||
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
|
|
||||||
currentEpisodeIndex: episodeIndex,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
introEndTime: playRecord?.introEndTime,
|
introEndTime: playRecord?.introEndTime,
|
||||||
outroStartTime: playRecord?.outroStartTime,
|
outroStartTime: playRecord?.outroStartTime,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load video details", error);
|
console.error("Failed to load play record", error);
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
switchSource: async (newSourceIndex: number) => {
|
|
||||||
const { sources, currentEpisodeIndex, status, detail } = get();
|
|
||||||
if (!detail || newSourceIndex < 0 || newSourceIndex >= sources.length) return;
|
|
||||||
|
|
||||||
const newSource = sources[newSourceIndex];
|
|
||||||
const position = status?.isLoaded ? status.positionMillis : 0;
|
|
||||||
|
|
||||||
set({ isLoading: true, showSourceModal: false });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const videoDetail = await api.getVideoDetail(newSource.source, newSource.id.toString());
|
|
||||||
const searchResults = await api.searchVideo(videoDetail.title, newSource.source);
|
|
||||||
if (!searchResults.results || searchResults.results.length === 0) {
|
|
||||||
throw new Error("No episodes found for this source.");
|
|
||||||
}
|
|
||||||
const sourceDetail = searchResults.results[0];
|
|
||||||
const episodes = sourceDetail.episodes.map((ep, index) => ({ url: ep, title: `第 ${index + 1} 集` }));
|
|
||||||
|
|
||||||
set({
|
|
||||||
detail: {
|
|
||||||
...detail,
|
|
||||||
videoInfo: videoDetail,
|
|
||||||
episodes,
|
|
||||||
},
|
|
||||||
episodes,
|
|
||||||
currentSourceIndex: newSourceIndex,
|
|
||||||
currentEpisodeIndex: currentEpisodeIndex < episodes.length ? currentEpisodeIndex : 0,
|
|
||||||
initialPosition: position,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to switch source", error);
|
|
||||||
set({ isLoading: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
playEpisode: (index) => {
|
playEpisode: (index) => {
|
||||||
const { episodes, videoRef } = get();
|
const { episodes, videoRef } = get();
|
||||||
@@ -194,7 +147,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setIntroEndTime: () => {
|
setIntroEndTime: () => {
|
||||||
const { status, detail, introEndTime: existingIntroEndTime } = get();
|
const { status, introEndTime: existingIntroEndTime } = get();
|
||||||
|
const detail = useDetailStore.getState().detail;
|
||||||
if (!status?.isLoaded || !detail) return;
|
if (!status?.isLoaded || !detail) return;
|
||||||
|
|
||||||
if (existingIntroEndTime) {
|
if (existingIntroEndTime) {
|
||||||
@@ -219,7 +173,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setOutroStartTime: () => {
|
setOutroStartTime: () => {
|
||||||
const { status, detail, outroStartTime: existingOutroStartTime } = get();
|
const { status, outroStartTime: existingOutroStartTime } = get();
|
||||||
|
const detail = useDetailStore.getState().detail;
|
||||||
if (!status?.isLoaded || !detail) return;
|
if (!status?.isLoaded || !detail) return;
|
||||||
|
|
||||||
if (existingOutroStartTime) {
|
if (existingOutroStartTime) {
|
||||||
@@ -245,21 +200,21 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
_savePlayRecord: (updates = {}) => {
|
_savePlayRecord: (updates = {}) => {
|
||||||
const { detail, currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
|
const { detail } = useDetailStore.getState();
|
||||||
|
const { currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
|
||||||
if (detail && status?.isLoaded) {
|
if (detail && status?.isLoaded) {
|
||||||
const { videoInfo } = detail;
|
|
||||||
const existingRecord = {
|
const existingRecord = {
|
||||||
introEndTime,
|
introEndTime,
|
||||||
outroStartTime,
|
outroStartTime,
|
||||||
};
|
};
|
||||||
PlayRecordManager.save(videoInfo.source, videoInfo.id, {
|
PlayRecordManager.save(detail.source, detail.id.toString(), {
|
||||||
title: videoInfo.title,
|
title: detail.title,
|
||||||
poster: videoInfo.poster || "",
|
poster: detail.poster || "",
|
||||||
index: currentEpisodeIndex,
|
index: currentEpisodeIndex,
|
||||||
total_episodes: episodes.length,
|
total_episodes: episodes.length,
|
||||||
play_time: status.positionMillis,
|
play_time: status.positionMillis,
|
||||||
total_time: status.durationMillis || 0,
|
total_time: status.durationMillis || 0,
|
||||||
source_name: videoInfo.source_name,
|
source_name: detail.source_name,
|
||||||
...existingRecord,
|
...existingRecord,
|
||||||
...updates,
|
...updates,
|
||||||
});
|
});
|
||||||
@@ -275,7 +230,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { detail, currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
|
const { currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
|
||||||
|
const detail = useDetailStore.getState().detail;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
outroStartTime &&
|
outroStartTime &&
|
||||||
@@ -317,10 +273,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
set({
|
set({
|
||||||
detail: null,
|
|
||||||
episodes: [],
|
episodes: [],
|
||||||
sources: [],
|
|
||||||
currentSourceIndex: 0,
|
|
||||||
currentEpisodeIndex: 0,
|
currentEpisodeIndex: 0,
|
||||||
status: null,
|
status: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { SettingsManager } from '@/services/storage';
|
import { SettingsManager } from '@/services/storage';
|
||||||
import { api } from '@/services/api';
|
import { api, ServerConfig } from '@/services/api';
|
||||||
import useHomeStore from './homeStore';
|
import useHomeStore from './homeStore';
|
||||||
|
|
||||||
|
|
||||||
@@ -15,7 +15,9 @@ interface SettingsState {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
isModalVisible: boolean;
|
isModalVisible: boolean;
|
||||||
|
serverConfig: ServerConfig | null;
|
||||||
loadSettings: () => Promise<void>;
|
loadSettings: () => Promise<void>;
|
||||||
|
fetchServerConfig: () => Promise<void>;
|
||||||
setApiBaseUrl: (url: string) => void;
|
setApiBaseUrl: (url: string) => void;
|
||||||
setM3uUrl: (url: string) => void;
|
setM3uUrl: (url: string) => void;
|
||||||
setRemoteInputEnabled: (enabled: boolean) => void;
|
setRemoteInputEnabled: (enabled: boolean) => void;
|
||||||
@@ -31,13 +33,14 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
liveStreamSources: [],
|
liveStreamSources: [],
|
||||||
remoteInputEnabled: false,
|
remoteInputEnabled: false,
|
||||||
isModalVisible: false,
|
isModalVisible: false,
|
||||||
|
serverConfig: null,
|
||||||
videoSource: {
|
videoSource: {
|
||||||
enabledAll: true,
|
enabledAll: true,
|
||||||
sources: {},
|
sources: {},
|
||||||
},
|
},
|
||||||
loadSettings: async () => {
|
loadSettings: async () => {
|
||||||
const settings = await SettingsManager.get();
|
const settings = await SettingsManager.get();
|
||||||
set({
|
set({
|
||||||
apiBaseUrl: settings.apiBaseUrl,
|
apiBaseUrl: settings.apiBaseUrl,
|
||||||
m3uUrl: settings.m3uUrl,
|
m3uUrl: settings.m3uUrl,
|
||||||
remoteInputEnabled: settings.remoteInputEnabled || false,
|
remoteInputEnabled: settings.remoteInputEnabled || false,
|
||||||
@@ -47,6 +50,15 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
api.setBaseUrl(settings.apiBaseUrl);
|
api.setBaseUrl(settings.apiBaseUrl);
|
||||||
|
await get().fetchServerConfig();
|
||||||
|
},
|
||||||
|
fetchServerConfig: async () => {
|
||||||
|
try {
|
||||||
|
const config = await api.getServerConfig();
|
||||||
|
set({ serverConfig: config });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch server config:", error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setApiBaseUrl: (url) => set({ apiBaseUrl: url }),
|
setApiBaseUrl: (url) => set({ apiBaseUrl: url }),
|
||||||
setM3uUrl: (url) => set({ m3uUrl: url }),
|
setM3uUrl: (url) => set({ m3uUrl: url }),
|
||||||
|
|||||||
24
stores/sourceStore.ts
Normal file
24
stores/sourceStore.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
import useDetailStore, { sourcesSelector } from "./detailStore";
|
||||||
|
|
||||||
|
interface SourceState {
|
||||||
|
toggleResourceEnabled: (resourceKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSourceStore = create<SourceState>((set, get) => ({
|
||||||
|
toggleResourceEnabled: (resourceKey: string) => {
|
||||||
|
const { videoSource, setVideoSource } = useSettingsStore.getState();
|
||||||
|
const isEnabled = videoSource.sources[resourceKey];
|
||||||
|
const newEnabledSources = { ...videoSource.sources, [resourceKey]: !isEnabled };
|
||||||
|
|
||||||
|
setVideoSource({
|
||||||
|
enabledAll: Object.values(newEnabledSources).every((enabled) => enabled),
|
||||||
|
sources: newEnabledSources,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useSources = () => useDetailStore(sourcesSelector);
|
||||||
|
|
||||||
|
export default useSourceStore;
|
||||||
@@ -1762,6 +1762,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.4.1.tgz#a3c247aceab35f75dd0aa4bfa85d2be5a4508688"
|
resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.4.1.tgz#a3c247aceab35f75dd0aa4bfa85d2be5a4508688"
|
||||||
integrity sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==
|
integrity sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==
|
||||||
|
|
||||||
|
"@react-native-cookies/cookies@^6.2.1":
|
||||||
|
version "6.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@react-native-cookies/cookies/-/cookies-6.2.1.tgz#54d50b9496400bbdc19e43c155f70f8f918999e3"
|
||||||
|
integrity sha512-D17wCA0DXJkGJIxkL74Qs9sZ3sA+c+kCoGmXVknW7bVw/W+Vv1m/7mWTNi9DLBZSRddhzYw8SU0aJapIaM/g5w==
|
||||||
|
dependencies:
|
||||||
|
invariant "^2.2.4"
|
||||||
|
|
||||||
"@react-native-tvos/config-tv@^0.0.10":
|
"@react-native-tvos/config-tv@^0.0.10":
|
||||||
version "0.0.10"
|
version "0.0.10"
|
||||||
resolved "https://registry.yarnpkg.com/@react-native-tvos/config-tv/-/config-tv-0.0.10.tgz#38fe1571e24c6790b43137d130832c68b366c295"
|
resolved "https://registry.yarnpkg.com/@react-native-tvos/config-tv/-/config-tv-0.0.10.tgz#38fe1571e24c6790b43137d130832c68b366c295"
|
||||||
|
|||||||
Reference in New Issue
Block a user