Files
KVideo/lib/store/settings-store.ts
T

305 lines
11 KiB
TypeScript

/**
* Settings Store - Manages application settings and preferences
*/
import type { VideoSource, SourceSubscription } from '@/lib/types';
import { DEFAULT_SOURCES } from '@/lib/api/default-sources';
import { PREMIUM_SOURCES } from '@/lib/api/premium-sources';
import { createSubscription } from '@/lib/utils/source-import-utils';
export type SortOption =
| 'default'
| 'relevance'
| 'latency-asc'
| 'date-desc'
| 'date-asc'
| 'rating-desc'
| 'name-asc'
| 'name-desc';
export type SearchDisplayMode = 'normal' | 'grouped';
export interface AppSettings {
sources: VideoSource[];
premiumSources: VideoSource[];
subscriptions: SourceSubscription[]; // Source subscriptions for auto-update
sortBy: SortOption;
searchHistory: boolean;
watchHistory: boolean;
passwordAccess: boolean;
accessPasswords: string[];
// Player settings
autoNextEpisode: boolean;
autoSkipIntro: boolean;
skipIntroSeconds: number;
autoSkipOutro: boolean;
skipOutroSeconds: number;
showModeIndicator: boolean; // Show '直连模式'/'代理模式' badge on player
// Search & Display settings
realtimeLatency: boolean; // Enable real-time latency ping updates
searchDisplayMode: SearchDisplayMode; // 'normal' = individual cards, 'grouped' = group same-name videos
episodeReverseOrder: boolean; // Persist episode list reverse state
}
import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers';
const SETTINGS_KEY = 'kvideo-settings';
export const getDefaultSources = (): VideoSource[] => DEFAULT_SOURCES;
export const getDefaultPremiumSources = (): VideoSource[] => PREMIUM_SOURCES;
function getEnvSubscriptions(customValue?: string): SourceSubscription[] {
const envValue = (customValue || process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '').trim();
if (!envValue) return [];
// 1. Try JSON
try {
const raw = JSON.parse(envValue);
if (Array.isArray(raw)) {
return raw
.filter((item: any) => item && typeof item.name === 'string' && typeof item.url === 'string')
.map((item: any) => createSubscription(item.name, item.url));
}
} catch (e) {
// Ignore JSON parse error, try direct URL
}
// 2. Try Simple URL (or comma separated)
// Check if it looks like a URL (basic check)
if (envValue.includes('http')) {
const urls = envValue.split(',').map(u => u.trim()).filter(u => u.length > 0);
return urls.map((url, index) => {
// Basic URL validation
if (!url.startsWith('http')) return null;
const name = urls.length > 1
? `系统预设源 ${index + 1}`
: `系统预设源`;
return createSubscription(name, url);
}).filter((s): s is SourceSubscription => s !== null);
}
return [];
}
// Debugging helper
// console.log("Environment Subscriptions:", getEnvSubscriptions());
export const settingsStore = {
getSettings(): AppSettings {
if (typeof window === 'undefined') {
return {
sources: getDefaultSources(),
premiumSources: getDefaultPremiumSources(),
subscriptions: getEnvSubscriptions(),
sortBy: 'default',
searchHistory: true,
watchHistory: true,
passwordAccess: false,
accessPasswords: [],
autoNextEpisode: true,
autoSkipIntro: false,
skipIntroSeconds: 0,
autoSkipOutro: false,
skipOutroSeconds: 0,
showModeIndicator: false,
realtimeLatency: false,
searchDisplayMode: 'normal',
episodeReverseOrder: false,
};
}
const stored = localStorage.getItem(SETTINGS_KEY);
if (!stored) {
return {
sources: getDefaultSources(),
premiumSources: getDefaultPremiumSources(),
subscriptions: getEnvSubscriptions(),
sortBy: 'default',
searchHistory: true,
watchHistory: true,
passwordAccess: false,
accessPasswords: [],
autoNextEpisode: true,
autoSkipIntro: false,
skipIntroSeconds: 0,
autoSkipOutro: false,
skipOutroSeconds: 0,
showModeIndicator: false,
realtimeLatency: false,
searchDisplayMode: 'normal',
episodeReverseOrder: false,
};
}
try {
const parsed = JSON.parse(stored);
// Get ENV subscriptions
const envSubscriptions = getEnvSubscriptions();
// Parse stored subscriptions
const storedSubscriptions = Array.isArray(parsed.subscriptions) ? parsed.subscriptions : [];
// Merge: ENV subscriptions take precedence for existence, but we want to keep local state (like lastUpdated) if possible
// However, for simplicity and ensuring "auto update" as user requested, if it's in ENV, we act as if it's a fresh/enforced source
// actually, let's just merge them by URL.
const mergedSubscriptions = [...storedSubscriptions];
envSubscriptions.forEach(envSub => {
const existingIndex = mergedSubscriptions.findIndex(s => s.url === envSub.url);
if (existingIndex > -1) {
// Update name if changed in ENV, but keep ID and lastUpdated from local to avoid unnecessary re-fetches or ID shifts if dependent
// BUT, if the user explicitly wants "ENV input... automatically update", maybe they mean the content updates.
// Let's just ensure it exists.
mergedSubscriptions[existingIndex] = {
...mergedSubscriptions[existingIndex],
name: envSub.name, // Allow renaming via ENV
autoRefresh: true // Enforce autoRefresh for ENV sources
};
} else {
mergedSubscriptions.push(envSub);
}
});
// Filter out invalid sources (missing baseUrl etc)
const validSources = (Array.isArray(parsed.sources) ? parsed.sources : getDefaultSources())
.filter((s: any) => s && s.id && s.name && s.baseUrl);
const validPremiumSources = (Array.isArray(parsed.premiumSources) ? parsed.premiumSources : getDefaultPremiumSources())
.filter((s: any) => s && s.id && s.name && s.baseUrl);
// Validate that parsed data has all required properties
return {
sources: validSources,
premiumSources: validPremiumSources,
subscriptions: mergedSubscriptions.filter((s: any) => s && s.id && s.name && s.url),
sortBy: parsed.sortBy || 'default',
searchHistory: parsed.searchHistory !== undefined ? parsed.searchHistory : true,
watchHistory: parsed.watchHistory !== undefined ? parsed.watchHistory : true,
passwordAccess: parsed.passwordAccess !== undefined ? parsed.passwordAccess : false,
accessPasswords: Array.isArray(parsed.accessPasswords) ? parsed.accessPasswords : [],
autoNextEpisode: parsed.autoNextEpisode !== undefined ? parsed.autoNextEpisode : true,
autoSkipIntro: parsed.autoSkipIntro !== undefined ? parsed.autoSkipIntro : false,
skipIntroSeconds: typeof parsed.skipIntroSeconds === 'number' ? parsed.skipIntroSeconds : 0,
autoSkipOutro: parsed.autoSkipOutro !== undefined ? parsed.autoSkipOutro : false,
skipOutroSeconds: typeof parsed.skipOutroSeconds === 'number' ? parsed.skipOutroSeconds : 0,
showModeIndicator: parsed.showModeIndicator !== undefined ? parsed.showModeIndicator : false,
realtimeLatency: parsed.realtimeLatency !== undefined ? parsed.realtimeLatency : false,
searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal',
episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false,
};
} catch {
// Even if localStorage fails, we should return defaults + ENV subscriptions
const envSubscriptions = getEnvSubscriptions();
return {
sources: getDefaultSources(),
premiumSources: getDefaultPremiumSources(),
subscriptions: envSubscriptions,
sortBy: 'default',
searchHistory: true,
watchHistory: true,
passwordAccess: false,
accessPasswords: [],
autoNextEpisode: true,
autoSkipIntro: false,
skipIntroSeconds: 0,
autoSkipOutro: false,
skipOutroSeconds: 0,
showModeIndicator: false,
realtimeLatency: false,
searchDisplayMode: 'normal',
episodeReverseOrder: false,
};
}
},
listeners: new Set<() => void>(),
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
},
notifyListeners(): void {
this.listeners.forEach((listener) => listener());
},
saveSettings(settings: AppSettings): void {
if (typeof window !== 'undefined') {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
this.notifyListeners();
}
},
exportSettings(includeHistory: boolean = true): string {
return exportSettings(this.getSettings(), includeHistory);
},
importSettings(jsonString: string): boolean {
return importSettings(jsonString, (s) => this.saveSettings(s), this.getSettings());
},
syncEnvSubscriptions(rawEnvValue: string): void {
if (typeof window === 'undefined') return;
const currentSettings = this.getSettings();
const envSubs = getEnvSubscriptions(rawEnvValue);
if (envSubs.length === 0) return;
const mergedSubscriptions = [...currentSettings.subscriptions];
let changed = false;
envSubs.forEach(envSub => {
const existingIndex = mergedSubscriptions.findIndex(s => s.url === envSub.url);
if (existingIndex > -1) {
// Only update if something meaningful changed to avoid unnecessary re-renders
if (mergedSubscriptions[existingIndex].name !== envSub.name) {
mergedSubscriptions[existingIndex] = {
...mergedSubscriptions[existingIndex],
name: envSub.name,
autoRefresh: true
};
changed = true;
}
} else {
mergedSubscriptions.push(envSub);
changed = true;
}
});
if (changed) {
this.saveSettings({
...currentSettings,
subscriptions: mergedSubscriptions
});
}
},
resetToDefaults(): void {
if (typeof window !== 'undefined') {
localStorage.removeItem(SETTINGS_KEY);
localStorage.removeItem(SEARCH_HISTORY_KEY);
localStorage.removeItem(WATCH_HISTORY_KEY);
// Clear all cookies
document.cookie.split(";").forEach((c) => {
document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
});
// Clear cache if available
if ('caches' in window) {
caches.keys().then((names) => {
names.forEach(name => caches.delete(name));
});
}
}
},
};