mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
355 lines
14 KiB
TypeScript
355 lines
14 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 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
|
|
customAdFilterCode?: string; // Custom dynamic ad filter JS/TS script
|
|
customAdFilterVersion?: number; // Custom dynamic ad filter version
|
|
// 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 : [],
|
|
customAdFilterCode: typeof parsed.customAdFilterCode === 'string' ? parsed.customAdFilterCode : '',
|
|
customAdFilterVersion: typeof parsed.customAdFilterVersion === 'number' ? parsed.customAdFilterVersion : 0,
|
|
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));
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|