/** * 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 LocaleOption = 'zh-CN' | 'zh-TW'; export type SortOption = | 'default' | 'relevance' | 'latency-asc' | 'date-desc' | 'date-asc' | 'rating-desc' | 'name-asc' | 'name-desc'; export type SearchDisplayMode = 'normal' | 'grouped'; export type AdFilterMode = 'off' | 'keyword' | 'heuristic' | 'aggressive'; export type ProxyMode = 'retry' | 'none' | 'always'; export const DEFAULT_SEEK_STEP_SECONDS = 10; export const MIN_SEEK_STEP_SECONDS = 1; export const MAX_SEEK_STEP_SECONDS = 120; export function normalizeSeekStepSeconds(value: unknown): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return DEFAULT_SEEK_STEP_SECONDS; } return Math.min(MAX_SEEK_STEP_SECONDS, Math.max(MIN_SEEK_STEP_SECONDS, Math.round(value))); } export interface AppSettings { sources: VideoSource[]; premiumSources: VideoSource[]; subscriptions: SourceSubscription[]; // Source subscriptions for auto-update sortBy: SortOption; searchHistory: boolean; watchHistory: boolean; // Player settings autoNextEpisode: boolean; autoSkipIntro: boolean; skipIntroSeconds: number; autoSkipOutro: boolean; skipOutroSeconds: number; seekStepSeconds: number; showModeIndicator: boolean; // Show '直连模式'/'代理模式' badge on player adFilter: boolean; // Filter ad tags from m3u8 (legacy, kept for compatibility) adFilterMode: AdFilterMode; // 'off' | 'keyword' | 'heuristic' | 'aggressive' adKeywords: string[]; // Dynamically loaded ad keywords // 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 fullscreenType: 'auto' | 'native' | 'window'; // Fullscreen mode preference: 'auto' (native on desktop, window on mobile) | 'native' | 'window' proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always' rememberScrollPosition: boolean; // Remember scroll position when navigating back or refreshing personalizedRecommendations: boolean; // Show personalized recommendations based on watch history videoTogetherEnabled: boolean; // Show VideoTogether entry on supported player pages // Danmaku settings danmakuEnabled: boolean; // Show danmaku overlay on video danmakuApiUrl: string; // Self-hosted danmaku API endpoint danmakuOpacity: number; // 0.1 - 1.0 danmakuFontSize: number; // px danmakuDisplayArea: number; // 0.25 | 0.5 | 0.75 | 1.0 locale: LocaleOption; // 'zh-CN' (Simplified) or 'zh-TW' (Traditional) blockedCategories: string[]; // Category keywords to hide from search results (e.g. '伦理') } 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()); // Shared default settings factory to avoid code duplication function getDefaultAppSettings(): AppSettings { return { sources: getDefaultSources(), premiumSources: getDefaultPremiumSources(), subscriptions: getEnvSubscriptions(), sortBy: 'default', searchHistory: true, watchHistory: true, autoNextEpisode: true, autoSkipIntro: false, skipIntroSeconds: 0, autoSkipOutro: false, skipOutroSeconds: 0, seekStepSeconds: DEFAULT_SEEK_STEP_SECONDS, showModeIndicator: false, adFilter: false, adFilterMode: 'heuristic', adKeywords: [], realtimeLatency: false, searchDisplayMode: 'normal', episodeReverseOrder: false, fullscreenType: 'auto', proxyMode: 'retry', rememberScrollPosition: true, personalizedRecommendations: true, videoTogetherEnabled: false, danmakuEnabled: false, danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '', danmakuOpacity: 0.7, danmakuFontSize: 20, danmakuDisplayArea: 0.5, locale: 'zh-CN', blockedCategories: [], }; } export function hasStoredAppSetting(key: keyof AppSettings): boolean { if (typeof window === 'undefined') { return false; } const stored = localStorage.getItem(SETTINGS_KEY); if (!stored) { return false; } try { const parsed = JSON.parse(stored); return Object.prototype.hasOwnProperty.call(parsed, key); } catch { return false; } } export const settingsStore = { getSettings(): AppSettings { // SSR: Return defaults if (typeof window === 'undefined') { return getDefaultAppSettings(); } // Client: No stored settings, return defaults const stored = localStorage.getItem(SETTINGS_KEY); if (!stored) { return getDefaultAppSettings(); } 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, 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, seekStepSeconds: normalizeSeekStepSeconds(parsed.seekStepSeconds), showModeIndicator: parsed.showModeIndicator !== undefined ? parsed.showModeIndicator : false, adFilter: parsed.adFilter !== undefined ? parsed.adFilter : false, adFilterMode: parsed.adFilterMode || 'heuristic', adKeywords: Array.isArray(parsed.adKeywords) ? parsed.adKeywords : [], realtimeLatency: parsed.realtimeLatency !== undefined ? parsed.realtimeLatency : false, searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal', episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false, fullscreenType: (parsed.fullscreenType === 'window' || parsed.fullscreenType === 'native' || parsed.fullscreenType === 'auto') ? parsed.fullscreenType : 'auto', proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry', rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true, personalizedRecommendations: parsed.personalizedRecommendations !== undefined ? parsed.personalizedRecommendations : true, videoTogetherEnabled: parsed.videoTogetherEnabled !== undefined ? parsed.videoTogetherEnabled : false, danmakuEnabled: parsed.danmakuEnabled !== undefined ? parsed.danmakuEnabled : false, danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''), danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7, danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20, danmakuDisplayArea: typeof parsed.danmakuDisplayArea === 'number' ? parsed.danmakuDisplayArea : 0.5, locale: parsed.locale === 'zh-TW' ? 'zh-TW' : 'zh-CN', blockedCategories: Array.isArray(parsed.blockedCategories) ? parsed.blockedCategories : [], }; } catch { // Even if localStorage fails, we should return defaults + ENV subscriptions return getDefaultAppSettings(); } }, 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)); }); } } }, };