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:
zimplexing
2025-07-14 22:55:55 +08:00
parent 0452bfe21f
commit 2bed3a4d00
21 changed files with 413 additions and 358 deletions

View File

@@ -9,6 +9,7 @@ import Toast from "react-native-toast-message";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import LoginModal from "@/components/LoginModal";
import useAuthStore from "@/stores/authStore";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -20,10 +21,13 @@ export default function RootLayout() {
});
const { loadSettings, remoteInputEnabled } = useSettingsStore();
const { startServer, stopServer } = useRemoteControlStore();
const { checkLoginStatus } = useAuthStore();
useEffect(() => {
loadSettings();
}, [loadSettings]);
loadSettings().then(() => {
checkLoginStatus();
});
}, [loadSettings, checkLoginStatus]);
useEffect(() => {
if (loaded || error) {

View File

@@ -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 { useLocalSearchParams, useRouter } from "expo-router";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { api, SearchResult } from "@/services/api";
import { getResolutionFromM3U8 } from "@/services/m3u8";
import { StyledButton } from "@/components/StyledButton";
import { useSettingsStore } from "@/stores/settingsStore";
import useDetailStore from "@/stores/detailStore";
export default function DetailScreen() {
const { source, q } = useLocalSearchParams();
const { q } = useLocalSearchParams<{ q: string }>();
const router = useRouter();
const [searchResults, setSearchResults] = useState<(SearchResult & { resolution?: string | null })[]>([]);
const [detail, setDetail] = useState<(SearchResult & { resolution?: string | null }) | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [allSourcesLoaded, setAllSourcesLoaded] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const { videoSource } = useSettingsStore();
const { detail, searchResults, loading, error, allSourcesLoaded, init, setDetail, abort } = useDetailStore();
useEffect(() => {
if (controllerRef.current) {
controllerRef.current.abort();
if (q) {
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 () => {
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;
controllerRef.current?.abort(); // Cancel any ongoing fetches
abort(); // Cancel any ongoing fetches
router.push({
pathname: "/play",
params: {
// Pass necessary identifiers, the rest will be in the store
q: detail.title,
source: detail.source,
id: detail.id.toString(),
episodeUrl: episodeName, // The "episode" is actually the URL
episodeIndex: episodeIndex.toString(),
title: detail.title,
poster: detail.poster,
},
});
};
@@ -217,7 +120,7 @@ export default function DetailScreen() {
<StyledButton
key={index}
style={styles.episodeButton}
onPress={() => handlePlay(episode, index)}
onPress={() => handlePlay(index)}
text={`${index + 1}`}
textStyle={styles.episodeButtonText}
/>

View File

@@ -5,9 +5,10 @@ import { ThemedText } from "@/components/ThemedText";
import { api } from "@/services/api";
import VideoCard from "@/components/VideoCard.tv";
import { useFocusEffect, useRouter } from "expo-router";
import { Search, Settings } from "lucide-react-native";
import { Search, Settings, LogOut } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton";
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
import useAuthStore from "@/stores/authStore";
const NUM_COLUMNS = 5;
const { width } = Dimensions.get("window");
@@ -31,6 +32,7 @@ export default function HomeScreen() {
selectCategory,
refreshPlayRecords,
} = useHomeStore();
const { isLoggedIn, logout } = useAuthStore();
useFocusEffect(
useCallback(() => {
@@ -132,6 +134,11 @@ export default function HomeScreen() {
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
{isLoggedIn && (
<StyledButton style={styles.searchButton} onPress={logout} variant="ghost">
<LogOut color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
)}
</View>
</View>

View File

@@ -10,50 +10,51 @@ import { SourceSelectionModal } from "@/components/SourceSelectionModal";
import { SeekingBar } from "@/components/SeekingBar";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import usePlayerStore from "@/stores/playerStore";
import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
import Toast from "react-native-toast-message";
import usePlayerStore from "@/stores/playerStore";
export default function PlayScreen() {
const videoRef = useRef<Video>(null);
const router = useRouter();
useKeepAwake();
const { source, id, episodeIndex, position } = useLocalSearchParams<{
source: string;
id: string;
const { episodeIndex: episodeIndexStr } = useLocalSearchParams<{
episodeIndex: string;
position: string;
}>();
const episodeIndex = parseInt(episodeIndexStr || "0", 10);
const { detail } = useDetailStore();
const {
detail,
episodes,
currentEpisodeIndex,
status,
isLoading,
showControls,
showEpisodeModal,
showSourceModal,
showNextEpisodeOverlay,
initialPosition,
introEndTime,
setVideoRef,
loadVideo,
handlePlaybackStatusUpdate,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
setShowNextEpisodeOverlay,
reset,
playEpisode,
togglePlayPause,
seek,
} = usePlayerStore();
useEffect(() => {
setVideoRef(videoRef);
if (source && id) {
loadVideo(source, id, parseInt(episodeIndex || "0", 10), parseInt(position || "0", 10));
}
// The detail is already in the detailStore, no need to load it again.
// 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 () => {
reset(); // Reset state when component unmounts
};
}, [source, id, episodeIndex, position, setVideoRef, loadVideo, reset]);
}, [episodeIndex, detail, setVideoRef, reset]);
const { onScreenPress } = useTVRemoteHandler();
@@ -70,17 +71,9 @@ export default function PlayScreen() {
const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction);
return () => backHandler.remove();
}, [
showControls,
showEpisodeModal,
showSourceModal,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
router,
]);
}, [showControls, setShowControls, router]);
if (!detail && isLoading) {
if (!detail) {
return (
<ThemedView style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#fff" />
@@ -88,7 +81,7 @@ export default function PlayScreen() {
);
}
const currentEpisode = episodes[currentEpisodeIndex];
const currentEpisode = detail.episodes[episodeIndex];
return (
<ThemedView focusable style={styles.container}>
@@ -96,9 +89,9 @@ export default function PlayScreen() {
<Video
ref={videoRef}
style={styles.videoPlayer}
source={{ uri: currentEpisode?.url }}
source={{ uri: currentEpisode }}
usePoster
posterSource={{ uri: detail?.videoInfo.poster ?? "" }}
posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => {
@@ -108,6 +101,10 @@ export default function PlayScreen() {
}
usePlayerStore.setState({ isLoading: false });
}}
onError={() => {
usePlayerStore.setState({ isLoading: false });
Toast.show({ type: "error", text1: "播放失败,请更换源后重试" });
}}
onLoadStart={() => usePlayerStore.setState({ isLoading: true })}
useNativeControls={false}
shouldPlay

View File

@@ -6,6 +6,7 @@ import { ThemedView } from "@/components/ThemedView";
import { StyledButton } from "@/components/StyledButton";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useSettingsStore } from "@/stores/settingsStore";
import useAuthStore from "@/stores/authStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { APIConfigSection } from "@/components/settings/APIConfigSection";
import { LiveStreamSection } from "@/components/settings/LiveStreamSection";
@@ -16,6 +17,7 @@ import Toast from "react-native-toast-message";
export default function SettingsScreen() {
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
const { lastMessage } = useRemoteControlStore();
const { isLoggedIn, logout } = useAuthStore();
const backgroundColor = useThemeColor({}, "background");
const [hasChanges, setHasChanges] = useState(false);