mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 12:13:43 +08:00
chore: update package versions and add user config sync API
- Bump version to 4.8.0 and update dependencies in package.json - Implement user config sync API for cross-device settings persistence - Add resolution badge auto-hide hook for improved user experience - Create useConfigSync hook to manage user settings synchronization with the server
This commit is contained in:
@@ -12,6 +12,7 @@ const RETRY_DELAY = 200;
|
||||
|
||||
/**
|
||||
* Fetch with timeout support
|
||||
* Accepts an optional external AbortSignal for cancellation cascade.
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
@@ -21,6 +22,18 @@ export async function fetchWithTimeout(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// If an external signal is provided, propagate its abort
|
||||
const externalSignal = options.signal;
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
clearTimeout(timeoutId);
|
||||
controller.abort();
|
||||
} else {
|
||||
const onAbort = () => controller.abort();
|
||||
externalSignal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
|
||||
@@ -10,7 +10,8 @@ import { fetchWithTimeout, withRetry } from './http-utils';
|
||||
async function searchVideosBySource(
|
||||
query: string,
|
||||
source: VideoSource,
|
||||
page: number = 1
|
||||
page: number = 1,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ results: VideoItem[]; source: string; responseTime: number; pagecount: number }> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -27,6 +28,7 @@ async function searchVideosBySource(
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
...source.headers,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -71,11 +73,12 @@ async function searchVideosBySource(
|
||||
export async function searchVideos(
|
||||
query: string,
|
||||
sources: VideoSource[],
|
||||
page: number = 1
|
||||
page: number = 1,
|
||||
signal?: AbortSignal
|
||||
): Promise<Array<{ results: VideoItem[]; source: string; responseTime?: number; pagecount?: number; error?: string }>> {
|
||||
const searchPromises = sources.map(async source => {
|
||||
try {
|
||||
return await searchVideosBySource(query, source, page);
|
||||
return await searchVideosBySource(query, source, page, signal);
|
||||
} catch (error) {
|
||||
return {
|
||||
results: [],
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* useConfigSync - Syncs user settings to the server for cross-device
|
||||
* and PWA persistence. Pulls on mount, pushes on change.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { getProfileId } from '@/lib/store/auth-store';
|
||||
|
||||
const DEBOUNCE_MS = 3000;
|
||||
|
||||
export function useConfigSync() {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasPulled = useRef(false);
|
||||
|
||||
const getHeaders = useCallback(() => {
|
||||
const profileId = getProfileId();
|
||||
if (!profileId) return null;
|
||||
return {
|
||||
'x-profile-id': profileId,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pull config from server on mount (once)
|
||||
useEffect(() => {
|
||||
if (hasPulled.current) return;
|
||||
hasPulled.current = true;
|
||||
|
||||
const pull = async () => {
|
||||
const headers = getHeaders();
|
||||
if (!headers) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/config', { headers });
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
const serverData = result.data;
|
||||
const local = settingsStore.getSettings();
|
||||
|
||||
// Only merge server data if it's newer or local is default
|
||||
const serverTime = serverData.updatedAt || 0;
|
||||
const localStr = localStorage.getItem('kvideo-settings');
|
||||
const localTime = localStr
|
||||
? JSON.parse(localStr)?._syncedAt || 0
|
||||
: 0;
|
||||
|
||||
if (serverTime > localTime) {
|
||||
// Server is newer — merge server settings into local
|
||||
const merged = { ...local };
|
||||
|
||||
if (serverData.sources?.length > 0) {
|
||||
merged.sources = serverData.sources;
|
||||
}
|
||||
if (serverData.premiumSources?.length > 0) {
|
||||
merged.premiumSources = serverData.premiumSources;
|
||||
}
|
||||
if (serverData.subscriptions?.length > 0) {
|
||||
merged.subscriptions = serverData.subscriptions;
|
||||
}
|
||||
if (serverData.blockedCategories) {
|
||||
merged.blockedCategories = serverData.blockedCategories;
|
||||
}
|
||||
|
||||
settingsStore.saveSettings(merged);
|
||||
|
||||
// Update sync timestamp
|
||||
const stored = localStorage.getItem('kvideo-settings');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
parsed._syncedAt = serverTime;
|
||||
localStorage.setItem(
|
||||
'kvideo-settings',
|
||||
JSON.stringify(parsed)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Server may not be available (e.g. Cloudflare Pages)
|
||||
}
|
||||
};
|
||||
|
||||
pull();
|
||||
}, [getHeaders]);
|
||||
|
||||
// Push config to server on settings change (debounced)
|
||||
useEffect(() => {
|
||||
const push = () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const headers = getHeaders();
|
||||
if (!headers) return;
|
||||
|
||||
try {
|
||||
const settings = settingsStore.getSettings();
|
||||
await fetch('/api/user/config', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
sources: settings.sources,
|
||||
premiumSources: settings.premiumSources,
|
||||
subscriptions: settings.subscriptions,
|
||||
blockedCategories: settings.blockedCategories,
|
||||
sortBy: settings.sortBy,
|
||||
locale: settings.locale,
|
||||
}),
|
||||
});
|
||||
|
||||
// Update local sync timestamp
|
||||
const stored = localStorage.getItem('kvideo-settings');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
parsed._syncedAt = Date.now();
|
||||
localStorage.setItem(
|
||||
'kvideo-settings',
|
||||
JSON.stringify(parsed)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — local storage is the primary source
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const unsubscribe = settingsStore.subscribe(push);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [getHeaders]);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export function useHomePage() {
|
||||
totalSources,
|
||||
performSearch,
|
||||
resetSearch,
|
||||
cancelSearch,
|
||||
loadCachedResults,
|
||||
applySorting,
|
||||
loadMore,
|
||||
@@ -147,6 +148,10 @@ export function useHomePage() {
|
||||
|
||||
|
||||
|
||||
const handleCancelSearch = useCallback(() => {
|
||||
cancelSearch();
|
||||
}, [cancelSearch]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setHasSearched(false);
|
||||
setQuery('');
|
||||
@@ -165,6 +170,7 @@ export function useHomePage() {
|
||||
totalSources,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handleCancelSearch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ParallelSearchResult {
|
||||
totalVideosFound: number;
|
||||
performSearch: (query: string, sources?: any[], sortBy?: SortOption) => Promise<void>;
|
||||
resetSearch: () => void;
|
||||
cancelSearch: () => void;
|
||||
loadCachedResults: (results: Video[], sources: any[]) => void;
|
||||
applySorting: (sortBy: SortOption) => void;
|
||||
loadMore: () => Promise<void>;
|
||||
@@ -85,6 +86,7 @@ export function useParallelSearch(
|
||||
totalVideosFound,
|
||||
performSearch,
|
||||
resetSearch,
|
||||
cancelSearch,
|
||||
loadCachedResults,
|
||||
applySorting,
|
||||
loadMore: loadMoreAction,
|
||||
|
||||
@@ -34,6 +34,7 @@ export function usePremiumHomePage() {
|
||||
totalSources,
|
||||
performSearch,
|
||||
resetSearch,
|
||||
cancelSearch,
|
||||
loadCachedResults,
|
||||
applySorting,
|
||||
loadMore,
|
||||
@@ -143,6 +144,7 @@ export function usePremiumHomePage() {
|
||||
totalSources,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handleCancelSearch: cancelSearch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface AppSettings {
|
||||
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';
|
||||
@@ -134,6 +135,7 @@ function getDefaultAppSettings(): AppSettings {
|
||||
danmakuFontSize: 20,
|
||||
danmakuDisplayArea: 0.5,
|
||||
locale: 'zh-CN',
|
||||
blockedCategories: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,6 +219,7 @@ export const settingsStore = {
|
||||
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
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Video } from '@/lib/types';
|
||||
import { getSourceName } from '@/lib/utils/source-names';
|
||||
import { calculateRelevanceScore, hasMinimumMatch } from '@/lib/utils/search';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
/**
|
||||
* Check if a video's category matches any blocked keyword.
|
||||
*/
|
||||
function isCategoryBlocked(video: any, blockedCategories: string[]): boolean {
|
||||
if (blockedCategories.length === 0) return false;
|
||||
const typeName = (video.type_name || video.vod_class || '').toLowerCase();
|
||||
return blockedCategories.some(cat => typeName.includes(cat.toLowerCase()));
|
||||
}
|
||||
|
||||
interface StreamHandlerParams {
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
@@ -43,6 +53,7 @@ export async function processSearchStream({
|
||||
|
||||
try {
|
||||
resetTimeout(); // Start initial timeout
|
||||
const blockedCategories = settingsStore.getSettings().blockedCategories;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -64,6 +75,7 @@ export async function processSearchStream({
|
||||
} else if (data.type === 'videos') {
|
||||
const newVideos: Video[] = data.videos
|
||||
.filter((video: any) => hasMinimumMatch(video.vod_name, currentQuery))
|
||||
.filter((video: any) => !isCategoryBlocked(video, blockedCategories))
|
||||
.map((video: any) => ({
|
||||
...video,
|
||||
sourceName: video.sourceDisplayName || getSourceName(video.source),
|
||||
|
||||
Reference in New Issue
Block a user