mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-03-06 18:57:31 +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:
@@ -1,15 +1,42 @@
|
||||
import { create } from "zustand";
|
||||
import Cookies from "@react-native-cookies/cookies";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
isLoginModalVisible: boolean;
|
||||
showLoginModal: () => void;
|
||||
hideLoginModal: () => void;
|
||||
checkLoginStatus: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useAuthStore = create<AuthState>((set) => ({
|
||||
isLoggedIn: false,
|
||||
isLoginModalVisible: false,
|
||||
showLoginModal: () => set({ isLoginModalVisible: true }),
|
||||
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;
|
||||
|
||||
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 { api, SearchResult, PlayRecord } from '@/services/api';
|
||||
import { PlayRecordManager } from '@/services/storage';
|
||||
import useAuthStore from './authStore';
|
||||
|
||||
export type RowItem = (SearchResult | PlayRecord) & {
|
||||
id: string;
|
||||
@@ -73,6 +74,11 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
|
||||
try {
|
||||
if (selectedCategory.type === 'record') {
|
||||
const { isLoggedIn } = useAuthStore.getState();
|
||||
if (!isLoggedIn) {
|
||||
set({ contentData: [], hasMore: false });
|
||||
return;
|
||||
}
|
||||
const records = await PlayRecordManager.getAll();
|
||||
const rowItems = Object.entries(records)
|
||||
.map(([key, record]) => {
|
||||
@@ -126,6 +132,21 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
},
|
||||
|
||||
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 hasRecords = Object.keys(records).length > 0;
|
||||
set(state => {
|
||||
|
||||
@@ -2,27 +2,18 @@ import { create } from "zustand";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { AVPlaybackStatus, Video } from "expo-av";
|
||||
import { RefObject } from "react";
|
||||
import { api, VideoDetail as ApiVideoDetail, SearchResult } from "@/services/api";
|
||||
import { PlayRecord, PlayRecordManager } from "@/services/storage";
|
||||
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
|
||||
|
||||
interface Episode {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface VideoDetail {
|
||||
videoInfo: ApiVideoDetail;
|
||||
episodes: Episode[];
|
||||
sources: SearchResult[];
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
videoRef: RefObject<Video> | null;
|
||||
detail: VideoDetail | null;
|
||||
episodes: Episode[];
|
||||
sources: SearchResult[];
|
||||
currentSourceIndex: number;
|
||||
currentEpisodeIndex: number;
|
||||
episodes: Episode[];
|
||||
status: AVPlaybackStatus | null;
|
||||
isLoading: boolean;
|
||||
showControls: boolean;
|
||||
@@ -36,8 +27,11 @@ interface PlayerState {
|
||||
introEndTime?: number;
|
||||
outroStartTime?: number;
|
||||
setVideoRef: (ref: RefObject<Video>) => void;
|
||||
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
|
||||
switchSource: (newSourceIndex: number) => Promise<void>;
|
||||
loadVideo: (
|
||||
source: string,
|
||||
episodeIndex: number,
|
||||
position?: number
|
||||
) => Promise<void>;
|
||||
playEpisode: (index: number) => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (duration: number) => void;
|
||||
@@ -57,10 +51,7 @@ interface PlayerState {
|
||||
|
||||
const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
videoRef: null,
|
||||
detail: null,
|
||||
episodes: [],
|
||||
sources: [],
|
||||
currentSourceIndex: 0,
|
||||
currentEpisodeIndex: 0,
|
||||
status: null,
|
||||
isLoading: true,
|
||||
@@ -78,76 +69,38 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
|
||||
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({
|
||||
isLoading: true,
|
||||
detail: null,
|
||||
episodes: [],
|
||||
sources: [],
|
||||
currentEpisodeIndex: 0,
|
||||
currentEpisodeIndex: episodeIndex,
|
||||
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({
|
||||
detail: { videoInfo: videoDetail, episodes, sources },
|
||||
episodes,
|
||||
sources,
|
||||
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
|
||||
currentEpisodeIndex: episodeIndex,
|
||||
isLoading: false,
|
||||
introEndTime: playRecord?.introEndTime,
|
||||
outroStartTime: playRecord?.outroStartTime,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load video details", error);
|
||||
console.error("Failed to load play record", error);
|
||||
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) => {
|
||||
const { episodes, videoRef } = get();
|
||||
@@ -194,7 +147,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
},
|
||||
|
||||
setIntroEndTime: () => {
|
||||
const { status, detail, introEndTime: existingIntroEndTime } = get();
|
||||
const { status, introEndTime: existingIntroEndTime } = get();
|
||||
const detail = useDetailStore.getState().detail;
|
||||
if (!status?.isLoaded || !detail) return;
|
||||
|
||||
if (existingIntroEndTime) {
|
||||
@@ -219,7 +173,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
},
|
||||
|
||||
setOutroStartTime: () => {
|
||||
const { status, detail, outroStartTime: existingOutroStartTime } = get();
|
||||
const { status, outroStartTime: existingOutroStartTime } = get();
|
||||
const detail = useDetailStore.getState().detail;
|
||||
if (!status?.isLoaded || !detail) return;
|
||||
|
||||
if (existingOutroStartTime) {
|
||||
@@ -245,21 +200,21 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
},
|
||||
|
||||
_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) {
|
||||
const { videoInfo } = detail;
|
||||
const existingRecord = {
|
||||
introEndTime,
|
||||
outroStartTime,
|
||||
};
|
||||
PlayRecordManager.save(videoInfo.source, videoInfo.id, {
|
||||
title: videoInfo.title,
|
||||
poster: videoInfo.poster || "",
|
||||
PlayRecordManager.save(detail.source, detail.id.toString(), {
|
||||
title: detail.title,
|
||||
poster: detail.poster || "",
|
||||
index: currentEpisodeIndex,
|
||||
total_episodes: episodes.length,
|
||||
play_time: status.positionMillis,
|
||||
total_time: status.durationMillis || 0,
|
||||
source_name: videoInfo.source_name,
|
||||
source_name: detail.source_name,
|
||||
...existingRecord,
|
||||
...updates,
|
||||
});
|
||||
@@ -275,7 +230,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { detail, currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
|
||||
const { currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
|
||||
const detail = useDetailStore.getState().detail;
|
||||
|
||||
if (
|
||||
outroStartTime &&
|
||||
@@ -317,10 +273,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
detail: null,
|
||||
episodes: [],
|
||||
sources: [],
|
||||
currentSourceIndex: 0,
|
||||
currentEpisodeIndex: 0,
|
||||
status: null,
|
||||
isLoading: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { SettingsManager } from '@/services/storage';
|
||||
import { api } from '@/services/api';
|
||||
import { api, ServerConfig } from '@/services/api';
|
||||
import useHomeStore from './homeStore';
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ interface SettingsState {
|
||||
};
|
||||
};
|
||||
isModalVisible: boolean;
|
||||
serverConfig: ServerConfig | null;
|
||||
loadSettings: () => Promise<void>;
|
||||
fetchServerConfig: () => Promise<void>;
|
||||
setApiBaseUrl: (url: string) => void;
|
||||
setM3uUrl: (url: string) => void;
|
||||
setRemoteInputEnabled: (enabled: boolean) => void;
|
||||
@@ -31,13 +33,14 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
liveStreamSources: [],
|
||||
remoteInputEnabled: false,
|
||||
isModalVisible: false,
|
||||
serverConfig: null,
|
||||
videoSource: {
|
||||
enabledAll: true,
|
||||
sources: {},
|
||||
},
|
||||
loadSettings: async () => {
|
||||
const settings = await SettingsManager.get();
|
||||
set({
|
||||
set({
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
m3uUrl: settings.m3uUrl,
|
||||
remoteInputEnabled: settings.remoteInputEnabled || false,
|
||||
@@ -47,6 +50,15 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
},
|
||||
});
|
||||
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 }),
|
||||
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;
|
||||
Reference in New Issue
Block a user