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:
kuekhaoyang
2026-03-18 12:44:05 +08:00
parent 54c1d353d7
commit 3564ce1ede
27 changed files with 721 additions and 215 deletions
+13
View File
@@ -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,
+6 -3
View File
@@ -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: [],
+134
View File
@@ -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]);
}
+6
View File
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+3
View File
@@ -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
+12
View File
@@ -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),