mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 00:03:42 +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:
@@ -42,3 +42,6 @@ next-env.d.ts
|
||||
|
||||
# unused/generated files
|
||||
contrast-test-results.json
|
||||
|
||||
# user data (server-side config sync)
|
||||
/.data/
|
||||
|
||||
@@ -61,10 +61,13 @@
|
||||
- **搜索结果显示**:支持默认显示和合并同名源两种模式
|
||||
- **实时延迟监测**:可选实时显示各源的网络延迟
|
||||
- **清晰度标签**:自动解析并显示视频清晰度(4K/蓝光/1080P/720P/HD 等),方便快速分辨源质量
|
||||
- **实际分辨率检测**:播放视频时自动检测并显示实际视频分辨率(如 1920x1080),不依赖源标签,显示真实清晰度
|
||||
- **实际分辨率检测**:播放视频时自动检测并显示实际视频分辨率(如 1920x1080),不依赖源标签,显示真实清晰度。分辨率标签 5 秒后自动隐藏,鼠标移动时重新显示
|
||||
- **繁体中文搜索**:自动将繁体中文转换为简体中文进行搜索,确保繁体输入也能搜到结果
|
||||
- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示,智能合并同名分类标签,展开/折叠状态持久化记忆
|
||||
- **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签
|
||||
- **搜索取消**:搜索进行中可随时点击"取消"按钮终止搜索,释放资源
|
||||
- **内容类目过滤**:在设置中添加屏蔽关键词(如"伦理"),匹配类目的视频将自动从搜索结果中过滤
|
||||
- **搜索性能优化**:服务端支持客户端断开检测(AbortSignal),每源超时保护,总结果数上限,防止内存溢出
|
||||
|
||||
### 多线路折叠
|
||||
|
||||
@@ -86,6 +89,7 @@
|
||||
- **多线路折叠**:频道多线路默认显示前 3 条,可点击展开查看全部
|
||||
- **自动切源**:当视频源不可用时,自动选择延迟最低的可用源
|
||||
- **自定义请求头**:自动解析 M3U 中的 `http-user-agent` 和 `http-referrer` 属性,通过代理传递
|
||||
- **User-Agent 智能代理**:当频道指定自定义 User-Agent 时,自动走代理路径避免浏览器限制,解决 CCTV 等频道仅有声音无画面的问题
|
||||
- **流媒体代理**:内置 HLS 流代理,自动处理 CORS 问题和 M3U8 URL 重写
|
||||
- **智能内容检测**:当 content-type 不明确时,检查响应体内容自动识别 M3U8 格式,同时保持二进制流数据完整性
|
||||
- **重定向跟随**:自动跟随 HTTP 3xx 重定向,提升兼容性
|
||||
@@ -167,6 +171,15 @@
|
||||
- **可安装应用**:支持将 KVideo 安装为独立应用
|
||||
- **Service Worker**:离线缓存和资源预加载
|
||||
- **全屏体验**:独立应用模式下的沉浸式体验
|
||||
- **配置同步**:iOS Safari 添加到主屏幕后,视频源和设置自动从服务端同步,无需重新配置
|
||||
|
||||
### 跨设备配置同步
|
||||
|
||||
- **服务端存储**:视频源、订阅、显示设置等自动同步到服务端(文件存储,无需 Redis)
|
||||
- **自动拉取**:打开应用时自动从服务端拉取最新配置
|
||||
- **自动推送**:设置变更后自动延迟推送到服务端(防抖 3 秒)
|
||||
- **多设备支持**:电脑端配置的源和设置,手机端打开即可使用
|
||||
- **PWA 兼容**:iOS PWA 模式下配置不再丢失
|
||||
|
||||
### 无障碍设计
|
||||
|
||||
|
||||
@@ -1,84 +1,94 @@
|
||||
/**
|
||||
* Parallel Streaming Search API Route
|
||||
* Searches all sources in parallel and streams results immediately as they arrive
|
||||
* No waiting - results flow in real-time
|
||||
* Searches all sources in parallel and streams results immediately as they arrive.
|
||||
* Supports abort via request.signal when clients disconnect.
|
||||
* Caps results per source and total to prevent OOM.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { searchVideos } from '@/lib/api/client';
|
||||
import { getSourceById } from '@/lib/api/video-sources';
|
||||
import { getSourceName } from '@/lib/utils/source-names';
|
||||
import { traditionalToSimplified } from '@/lib/utils/chinese-convert';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const MAX_TOTAL_VIDEOS = 2000;
|
||||
const MAX_PAGES_PER_SOURCE = 3;
|
||||
const PER_SOURCE_TIMEOUT_MS = 20000;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Use the request signal for abort detection
|
||||
const signal = request.signal;
|
||||
|
||||
const safeSend = (data: object) => {
|
||||
if (signal.aborted) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
// Controller may be closed
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { query, sources: sourceConfigs, page = 1 } = body;
|
||||
const { query, sources: sourceConfigs } = body;
|
||||
|
||||
// Validate input
|
||||
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Invalid query'
|
||||
})}\n\n`));
|
||||
safeSend({ type: 'error', message: 'Invalid query' });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert Traditional Chinese to Simplified Chinese for broader search compatibility
|
||||
const normalizedQuery = traditionalToSimplified(query.trim());
|
||||
|
||||
// Use provided sources or fallback to empty (client should provide them)
|
||||
const sources = Array.isArray(sourceConfigs) && sourceConfigs.length > 0
|
||||
? sourceConfigs
|
||||
: [];
|
||||
|
||||
if (sources.length === 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'No valid sources provided'
|
||||
})}\n\n`));
|
||||
safeSend({ type: 'error', message: 'No valid sources provided' });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send initial status
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'start',
|
||||
totalSources: sources.length
|
||||
})}\n\n`));
|
||||
safeSend({ type: 'start', totalSources: sources.length });
|
||||
|
||||
|
||||
|
||||
// Track progress
|
||||
let completedSources = 0;
|
||||
let totalVideosFound = 0;
|
||||
let maxPageCount = 1;
|
||||
|
||||
// Search all sources in PARALLEL - don't wait for all to finish
|
||||
const searchPromises = sources.map(async (source: any) => {
|
||||
const startTime = performance.now(); // Track start time
|
||||
try {
|
||||
if (signal.aborted) return;
|
||||
|
||||
// Search page 1 for this source
|
||||
const result = await searchVideos(normalizedQuery, [source], 1);
|
||||
const endTime = performance.now(); // Track end time
|
||||
const latency = Math.round(endTime - startTime); // Calculate latency in ms
|
||||
const startTime = performance.now();
|
||||
|
||||
// Per-source timeout via AbortController
|
||||
const sourceController = new AbortController();
|
||||
const sourceTimeout = setTimeout(
|
||||
() => sourceController.abort(),
|
||||
PER_SOURCE_TIMEOUT_MS
|
||||
);
|
||||
|
||||
// Cascade request abort to source controller
|
||||
const onRequestAbort = () => sourceController.abort();
|
||||
signal.addEventListener('abort', onRequestAbort, { once: true });
|
||||
|
||||
try {
|
||||
const result = await searchVideos(
|
||||
normalizedQuery, [source], 1, sourceController.signal
|
||||
);
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
const videos = result[0]?.results || [];
|
||||
const pagecount = result[0]?.pagecount ?? 1;
|
||||
|
||||
completedSources++;
|
||||
totalVideosFound += videos.length;
|
||||
|
||||
// Stream page 1 videos immediately
|
||||
if (videos.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
if (videos.length > 0 && !signal.aborted) {
|
||||
safeSend({
|
||||
type: 'videos',
|
||||
videos: videos.map((video: any) => ({
|
||||
...video,
|
||||
@@ -89,29 +99,35 @@ export async function POST(request: NextRequest) {
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
latency,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
|
||||
// Send progress update for page 1
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeSend({
|
||||
type: 'progress',
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
totalVideosFound
|
||||
})}\n\n`));
|
||||
totalVideosFound,
|
||||
});
|
||||
|
||||
// Auto-fetch remaining pages (capped)
|
||||
if (pagecount > 1 && totalVideosFound < MAX_TOTAL_VIDEOS && !signal.aborted) {
|
||||
const maxPages = Math.min(pagecount, MAX_PAGES_PER_SOURCE);
|
||||
const remainingPages = Array.from(
|
||||
{ length: maxPages - 1 }, (_, i) => i + 2
|
||||
);
|
||||
|
||||
for (const pg of remainingPages) {
|
||||
if (signal.aborted || totalVideosFound >= MAX_TOTAL_VIDEOS) break;
|
||||
|
||||
// Auto-fetch remaining pages if pagecount > 1
|
||||
if (pagecount > 1) {
|
||||
const remainingPages = Array.from({ length: pagecount - 1 }, (_, i) => i + 2);
|
||||
const pagePromises = remainingPages.map(async (pg) => {
|
||||
try {
|
||||
const pageResult = await searchVideos(normalizedQuery, [source], pg);
|
||||
const pageResult = await searchVideos(
|
||||
normalizedQuery, [source], pg, sourceController.signal
|
||||
);
|
||||
const pageVideos = pageResult[0]?.results || [];
|
||||
|
||||
totalVideosFound += pageVideos.length;
|
||||
|
||||
if (pageVideos.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
if (pageVideos.length > 0 && !signal.aborted) {
|
||||
safeSend({
|
||||
type: 'videos',
|
||||
videos: pageVideos.map((video: any) => ({
|
||||
...video,
|
||||
@@ -122,65 +138,64 @@ export async function POST(request: NextRequest) {
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
latency,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
|
||||
// Progress update for each additional page
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeSend({
|
||||
type: 'progress',
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
totalVideosFound
|
||||
})}\n\n`));
|
||||
|
||||
} catch (pageError) {
|
||||
console.error(`[Search Parallel] Source ${source.id} page ${pg} failed:`, pageError);
|
||||
totalVideosFound,
|
||||
});
|
||||
} catch {
|
||||
// Page fetch failed, continue
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(pagePromises);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
// Log error but continue with other sources
|
||||
console.error(`[Search Parallel] Source ${source.id} failed after ${latency}ms:`, error);
|
||||
console.error(
|
||||
`[Search] Source ${source.id} failed after ${latency}ms:`,
|
||||
error
|
||||
);
|
||||
completedSources++;
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeSend({
|
||||
type: 'progress',
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
totalVideosFound
|
||||
})}\n\n`));
|
||||
totalVideosFound,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(sourceTimeout);
|
||||
signal.removeEventListener('abort', onRequestAbort);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all sources to complete
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
|
||||
|
||||
// Send completion signal
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'complete',
|
||||
totalVideosFound,
|
||||
totalSources: sources.length,
|
||||
maxPageCount
|
||||
})}\n\n`));
|
||||
if (!signal.aborted) {
|
||||
safeSend({
|
||||
type: 'complete',
|
||||
totalVideosFound,
|
||||
totalSources: sources.length,
|
||||
maxPageCount: MAX_PAGES_PER_SOURCE,
|
||||
});
|
||||
}
|
||||
|
||||
controller.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
})}\n\n`));
|
||||
if (!signal.aborted) {
|
||||
console.error('Search error:', error);
|
||||
safeSend({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
@@ -191,5 +206,3 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* User Config Sync API Route
|
||||
* File-based settings persistence for cross-device and PWA support.
|
||||
* Stores user settings (sources, IPTV, display preferences) server-side
|
||||
* so they persist across browsers, devices, and PWA installs.
|
||||
*
|
||||
* No external dependencies required — uses local JSON files.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), '.data', 'user-config');
|
||||
|
||||
async function ensureDir() {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function getFilePath(profileId: string): string {
|
||||
// Sanitize profileId to prevent path traversal
|
||||
const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
return path.join(DATA_DIR, `${safe}.json`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureDir();
|
||||
const filePath = getFilePath(profileId);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
return NextResponse.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return NextResponse.json({ success: true, data: null });
|
||||
}
|
||||
console.error('Config read error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read config' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureDir();
|
||||
const body = await request.json();
|
||||
const filePath = getFilePath(profileId);
|
||||
|
||||
// Merge with existing data if present
|
||||
let existing: any = {};
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
existing = JSON.parse(content);
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
|
||||
const merged = { ...existing, ...body, updatedAt: Date.now() };
|
||||
await fs.writeFile(filePath, JSON.stringify(merged, null, 2), 'utf-8');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Config write error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save config' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ function HomePage() {
|
||||
totalSources,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handleCancelSearch,
|
||||
} = useHomePage();
|
||||
|
||||
// Real-time latency pinging
|
||||
@@ -48,6 +49,7 @@ function HomePage() {
|
||||
<SearchForm
|
||||
onSearch={handleSearch}
|
||||
onClear={handleReset}
|
||||
onCancelSearch={handleCancelSearch}
|
||||
isLoading={loading}
|
||||
initialQuery={query}
|
||||
currentSource=""
|
||||
|
||||
@@ -21,6 +21,7 @@ function PremiumHomePage() {
|
||||
totalSources,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handleCancelSearch,
|
||||
} = usePremiumHomePage();
|
||||
|
||||
return (
|
||||
@@ -36,6 +37,7 @@ function PremiumHomePage() {
|
||||
<SearchForm
|
||||
onSearch={handleSearch}
|
||||
onClear={handleReset}
|
||||
onCancelSearch={handleCancelSearch}
|
||||
isLoading={loading}
|
||||
initialQuery={query}
|
||||
currentSource=""
|
||||
|
||||
@@ -23,6 +23,9 @@ export function usePremiumSettingsPage() {
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(20);
|
||||
const [danmakuDisplayArea, setDanmakuDisplayArea] = useState(0.5);
|
||||
|
||||
// Content filter
|
||||
const [blockedCategories, setBlockedCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sources come from main settings store
|
||||
const settings = settingsStore.getSettings();
|
||||
@@ -40,6 +43,9 @@ export function usePremiumSettingsPage() {
|
||||
setDanmakuOpacity(modeSettings.danmakuOpacity);
|
||||
setDanmakuFontSize(modeSettings.danmakuFontSize);
|
||||
setDanmakuDisplayArea(modeSettings.danmakuDisplayArea);
|
||||
|
||||
// blockedCategories is global
|
||||
setBlockedCategories(settings.blockedCategories || []);
|
||||
}, []);
|
||||
|
||||
// --- Source management (uses main settingsStore) ---
|
||||
@@ -137,6 +143,12 @@ export function usePremiumSettingsPage() {
|
||||
savePremiumModeSetting({ danmakuDisplayArea: value });
|
||||
};
|
||||
|
||||
const handleBlockedCategoriesChange = (categories: string[]) => {
|
||||
setBlockedCategories(categories);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({ ...currentSettings, blockedCategories: categories });
|
||||
};
|
||||
|
||||
return {
|
||||
premiumSources,
|
||||
isAddModalOpen,
|
||||
@@ -171,5 +183,7 @@ export function usePremiumSettingsPage() {
|
||||
handleDanmakuFontSizeChange,
|
||||
danmakuDisplayArea,
|
||||
handleDanmakuDisplayAreaChange,
|
||||
blockedCategories,
|
||||
handleBlockedCategoriesChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function PremiumSettingsPage() {
|
||||
handleDanmakuFontSizeChange,
|
||||
danmakuDisplayArea,
|
||||
handleDanmakuDisplayAreaChange,
|
||||
blockedCategories,
|
||||
handleBlockedCategoriesChange,
|
||||
} = usePremiumSettingsPage();
|
||||
|
||||
return (
|
||||
@@ -97,6 +99,8 @@ export default function PremiumSettingsPage() {
|
||||
onRememberScrollPositionChange={handleRememberScrollPositionChange}
|
||||
locale={locale}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
blockedCategories={blockedCategories}
|
||||
onBlockedCategoriesChange={handleBlockedCategoriesChange}
|
||||
/>
|
||||
|
||||
{/* Premium Source Management */}
|
||||
|
||||
@@ -33,6 +33,9 @@ export function useSettingsPage() {
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(20);
|
||||
const [danmakuDisplayArea, setDanmakuDisplayArea] = useState(0.5);
|
||||
|
||||
// Content filter
|
||||
const [blockedCategories, setBlockedCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setSources(settings.sources || []);
|
||||
@@ -48,6 +51,7 @@ export function useSettingsPage() {
|
||||
setDanmakuOpacity(settings.danmakuOpacity);
|
||||
setDanmakuFontSize(settings.danmakuFontSize);
|
||||
setDanmakuDisplayArea(settings.danmakuDisplayArea);
|
||||
setBlockedCategories(settings.blockedCategories || []);
|
||||
}, []);
|
||||
|
||||
const handleSourcesChange = (newSources: VideoSource[]) => {
|
||||
@@ -304,6 +308,15 @@ export function useSettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlockedCategoriesChange = (categories: string[]) => {
|
||||
setBlockedCategories(categories);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
blockedCategories: categories,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestoreDefaults = () => {
|
||||
const defaults = getDefaultSources();
|
||||
handleSourcesChange(defaults);
|
||||
@@ -364,5 +377,7 @@ export function useSettingsPage() {
|
||||
handleDanmakuFontSizeChange,
|
||||
danmakuDisplayArea,
|
||||
handleDanmakuDisplayAreaChange,
|
||||
blockedCategories,
|
||||
handleBlockedCategoriesChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ export default function SettingsPage() {
|
||||
handleDanmakuFontSizeChange,
|
||||
danmakuDisplayArea,
|
||||
handleDanmakuDisplayAreaChange,
|
||||
blockedCategories,
|
||||
handleBlockedCategoriesChange,
|
||||
} = useSettingsPage();
|
||||
|
||||
return (
|
||||
@@ -106,6 +108,8 @@ export default function SettingsPage() {
|
||||
onRememberScrollPositionChange={handleRememberScrollPositionChange}
|
||||
locale={locale}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
blockedCategories={blockedCategories}
|
||||
onBlockedCategoriesChange={handleBlockedCategoriesChange}
|
||||
/>
|
||||
|
||||
{/* Per-User Source Settings (visible to all logged-in users) */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { useFavoritesStore } from '@/lib/store/favorites-store';
|
||||
import { useCloudSync } from '@/lib/hooks/useCloudSync';
|
||||
import { useConfigSync } from '@/lib/hooks/useConfigSync';
|
||||
import { getSession } from '@/lib/store/auth-store';
|
||||
|
||||
// 防抖函数,防止频繁请求
|
||||
@@ -18,6 +19,9 @@ function debounce(fn: Function, delay: number) {
|
||||
export function AutoSync() {
|
||||
const { pushToCloud, pullFromCloud } = useCloudSync();
|
||||
|
||||
// Config sync (sources, settings) — works without Redis, file-based
|
||||
useConfigSync();
|
||||
|
||||
useEffect(() => {
|
||||
const session = getSession();
|
||||
if (!session) return; // 未登录不进行同步
|
||||
|
||||
@@ -8,6 +8,7 @@ interface SearchLoadingAnimationProps {
|
||||
totalSources?: number;
|
||||
isPaused?: boolean;
|
||||
onComplete?: (checkedSources: number, totalSources: number) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function SearchLoadingAnimation({
|
||||
@@ -16,6 +17,7 @@ export function SearchLoadingAnimation({
|
||||
totalSources = 16,
|
||||
isPaused = false,
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: SearchLoadingAnimationProps) {
|
||||
const [dots, setDots] = useState('');
|
||||
const dotIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -123,7 +125,17 @@ export function SearchLoadingAnimation({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium">{Math.round(progress)}%</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-medium">{Math.round(progress)}%</span>
|
||||
{!isComplete && onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-2 py-0.5 rounded-[var(--radius-full)] bg-[var(--glass-bg)] hover:bg-red-500/20 text-[10px] transition-colors cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +200,11 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
video.load();
|
||||
|
||||
const proxiedUrl = getProxiedUrl(url, channel.httpUserAgent, channel.httpReferrer);
|
||||
const hasCustomHeaders = !!(channel.httpUserAgent || channel.httpReferrer);
|
||||
// When custom headers are needed, skip direct attempt (browsers cannot set
|
||||
// User-Agent on XHR/fetch). Always go through our proxy which can forward
|
||||
// the headers server-side. This fixes audio-only issues on CCTV and similar.
|
||||
const initialUrl = hasCustomHeaders ? proxiedUrl : url;
|
||||
|
||||
// Global loading timeout
|
||||
let loadingResolved = false;
|
||||
@@ -315,8 +320,8 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
}
|
||||
};
|
||||
|
||||
// First try direct URL with HLS.js
|
||||
hls.loadSource(url);
|
||||
// First try initial URL (direct or proxied based on custom headers)
|
||||
hls.loadSource(initialUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
// Filter HEVC levels to prevent audio-only playback
|
||||
@@ -337,12 +342,17 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS (Safari/iOS)
|
||||
video.src = url;
|
||||
video.src = initialUrl;
|
||||
video.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
video.addEventListener('error', () => {
|
||||
// If direct failed, try proxy; if already proxied, fail
|
||||
if (initialUrl === proxiedUrl) {
|
||||
markError('播放错误');
|
||||
return;
|
||||
}
|
||||
video.src = proxiedUrl;
|
||||
video.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
@@ -354,12 +364,16 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
}, { once: true });
|
||||
} else {
|
||||
// Direct video fallback
|
||||
video.src = url;
|
||||
video.src = initialUrl;
|
||||
video.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
video.addEventListener('error', () => {
|
||||
if (initialUrl === proxiedUrl) {
|
||||
markError('播放错误,请尝试其他频道');
|
||||
return;
|
||||
}
|
||||
video.src = proxiedUrl;
|
||||
video.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useHlsPlayer } from './hooks/useHlsPlayer';
|
||||
import { useAutoSkip } from './hooks/useAutoSkip';
|
||||
import { useStallDetection } from './hooks/useStallDetection';
|
||||
import { useVideoResolution } from './hooks/useVideoResolution';
|
||||
import { useResolutionBadge } from './hooks/useResolutionBadge';
|
||||
import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper';
|
||||
import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper';
|
||||
import { DanmakuCanvas } from './DanmakuCanvas';
|
||||
@@ -57,6 +58,7 @@ export function DesktopVideoPlayer({
|
||||
|
||||
// Detect actual video resolution
|
||||
const videoResolution = useVideoResolution(refs.videoRef);
|
||||
const { badgeVisible, flashBadge } = useResolutionBadge(videoResolution);
|
||||
|
||||
// Notify parent when resolution is detected
|
||||
React.useEffect(() => {
|
||||
@@ -206,7 +208,7 @@ export function DesktopVideoPlayer({
|
||||
ref={containerRef}
|
||||
className={`kvideo-container relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.isFullscreen && fullscreenType === 'window' ? 'is-web-fullscreen' : ''
|
||||
} ${shouldForceLandscape ? 'force-landscape' : ''}`}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseMove={() => { handleMouseMove(); flashBadge(); }}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
>
|
||||
{/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */}
|
||||
@@ -245,10 +247,10 @@ export function DesktopVideoPlayer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video Resolution Badge - shows actual resolution from video stream */}
|
||||
{/* Video Resolution Badge - auto-hides after 5 seconds */}
|
||||
{videoResolution && (
|
||||
<div className="absolute top-3 left-3 z-20 pointer-events-none">
|
||||
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${videoResolution.color} opacity-80`}>
|
||||
<div className={`absolute top-3 left-3 z-20 pointer-events-none transition-opacity duration-500 ${badgeVisible ? 'opacity-80' : 'opacity-0'}`}>
|
||||
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${videoResolution.color}`}>
|
||||
{videoResolution.label}
|
||||
<span className="font-normal opacity-80">{videoResolution.width}x{videoResolution.height}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Resolution Badge Auto-Hide Hook
|
||||
* Shows the resolution badge when detected, then auto-hides after a delay.
|
||||
* Reappears briefly on user interaction (mouse move / touch).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { VideoResolutionInfo } from './useVideoResolution';
|
||||
|
||||
const AUTO_HIDE_DELAY = 5000; // 5 seconds
|
||||
|
||||
export function useResolutionBadge(resolution: VideoResolutionInfo | null) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startHideTimer = useCallback(() => {
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
}, AUTO_HIDE_DELAY);
|
||||
}, [clearTimer]);
|
||||
|
||||
// Show badge when resolution first detected or changes
|
||||
useEffect(() => {
|
||||
if (resolution) {
|
||||
setVisible(true);
|
||||
startHideTimer();
|
||||
} else {
|
||||
setVisible(false);
|
||||
clearTimer();
|
||||
}
|
||||
}, [resolution, startHideTimer, clearTimer]);
|
||||
|
||||
// Briefly show badge on user interaction
|
||||
const flashBadge = useCallback(() => {
|
||||
if (!resolution) return;
|
||||
setVisible(true);
|
||||
startHideTimer();
|
||||
}, [resolution, startHideTimer]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => clearTimer, [clearTimer]);
|
||||
|
||||
return { badgeVisible: visible, flashBadge };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { SearchBox } from './SearchBox';
|
||||
interface SearchFormProps {
|
||||
onSearch: (query: string) => void;
|
||||
onClear?: () => void;
|
||||
onCancelSearch?: () => void;
|
||||
isLoading: boolean;
|
||||
initialQuery?: string;
|
||||
currentSource?: string;
|
||||
@@ -18,6 +19,7 @@ interface SearchFormProps {
|
||||
export function SearchForm({
|
||||
onSearch,
|
||||
onClear,
|
||||
onCancelSearch,
|
||||
isLoading,
|
||||
initialQuery = '',
|
||||
currentSource = '',
|
||||
@@ -43,6 +45,7 @@ export function SearchForm({
|
||||
currentSource={currentSource}
|
||||
checkedSources={checkedSources}
|
||||
totalSources={totalSources}
|
||||
onCancel={onCancelSearch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Following Liquid Glass design system
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { type SearchDisplayMode, type LocaleOption } from '@/lib/store/settings-store';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
|
||||
@@ -13,10 +14,12 @@ interface DisplaySettingsProps {
|
||||
searchDisplayMode: SearchDisplayMode;
|
||||
rememberScrollPosition: boolean;
|
||||
locale: LocaleOption;
|
||||
blockedCategories: string[];
|
||||
onRealtimeLatencyChange: (enabled: boolean) => void;
|
||||
onSearchDisplayModeChange: (mode: SearchDisplayMode) => void;
|
||||
onRememberScrollPositionChange: (enabled: boolean) => void;
|
||||
onLocaleChange: (locale: LocaleOption) => void;
|
||||
onBlockedCategoriesChange: (categories: string[]) => void;
|
||||
}
|
||||
|
||||
export function DisplaySettings({
|
||||
@@ -24,11 +27,29 @@ export function DisplaySettings({
|
||||
searchDisplayMode,
|
||||
rememberScrollPosition,
|
||||
locale,
|
||||
blockedCategories,
|
||||
onRealtimeLatencyChange,
|
||||
onSearchDisplayModeChange,
|
||||
onRememberScrollPositionChange,
|
||||
onLocaleChange,
|
||||
onBlockedCategoriesChange,
|
||||
}: DisplaySettingsProps) {
|
||||
const [newCategory, setNewCategory] = useState('');
|
||||
|
||||
const addCategory = () => {
|
||||
const trimmed = newCategory.trim();
|
||||
if (!trimmed) return;
|
||||
if (blockedCategories.includes(trimmed)) {
|
||||
setNewCategory('');
|
||||
return;
|
||||
}
|
||||
onBlockedCategoriesChange([...blockedCategories, trimmed]);
|
||||
setNewCategory('');
|
||||
};
|
||||
|
||||
const removeCategory = (cat: string) => {
|
||||
onBlockedCategoriesChange(blockedCategories.filter(c => c !== cat));
|
||||
};
|
||||
return (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4">显示设置</h2>
|
||||
@@ -126,6 +147,50 @@ export function DisplaySettings({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Blocked Categories */}
|
||||
<div className="mt-6">
|
||||
<h3 className="font-medium text-[var(--text-color)] mb-2">内容类目过滤</h3>
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
添加要从搜索结果中隐藏的类目关键词(如"伦理"),匹配的视频将不会显示
|
||||
</p>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addCategory()}
|
||||
placeholder="输入类目关键词..."
|
||||
className="flex-1 px-3 py-2 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<button
|
||||
onClick={addCategory}
|
||||
disabled={!newCategory.trim()}
|
||||
className="px-4 py-2 rounded-[var(--radius-2xl)] bg-[var(--accent-color)] text-white text-sm font-medium disabled:opacity-40 hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{blockedCategories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{blockedCategories.map(cat => (
|
||||
<span
|
||||
key={cat}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-[var(--radius-full)] bg-red-500/10 text-red-500 text-sm border border-red-500/20"
|
||||
>
|
||||
{cat}
|
||||
<button
|
||||
onClick={() => removeCategory(cat)}
|
||||
className="hover:text-red-700 cursor-pointer"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Generated
+139
-113
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.5.0",
|
||||
"version": "4.8.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.5.0",
|
||||
"version": "4.8.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@upstash/redis": "^1.37.0",
|
||||
"@vercel/analytics": "^2.0.0",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"next": "16.1.7",
|
||||
"opencc-js": "^1.0.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/next-on-pages": "^1.13.16",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^10",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-env": "^11.2.0",
|
||||
"tailwindcss": "^4",
|
||||
@@ -2520,26 +2520,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
|
||||
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
|
||||
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.7.tgz",
|
||||
"integrity": "sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-glob": "3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
|
||||
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
|
||||
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -2549,12 +2552,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
|
||||
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
|
||||
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -2564,12 +2568,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2579,12 +2584,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2594,12 +2600,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2609,12 +2616,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2624,12 +2632,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2639,12 +2648,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2658,6 +2668,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
@@ -2671,6 +2682,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
@@ -2680,6 +2692,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
@@ -3507,9 +3520,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/analytics": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.0.tgz",
|
||||
"integrity": "sha512-fP/ASXXz+1K/C2vWTnocd8RsGnkO9f1qOIDrhgQ3DagJtnea1EsM9AV9fDzjXlPIPb2vBQapxOIMCjtGIW8PZw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
|
||||
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@remix-run/react": "^2",
|
||||
@@ -3531,6 +3544,9 @@
|
||||
"next": {
|
||||
"optional": true
|
||||
},
|
||||
"nuxt": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -5007,12 +5023,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
|
||||
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.7.tgz",
|
||||
"integrity": "sha512-FTq1i/QDltzq+zf9aB/cKWAiZ77baG0V7h8dRQh3thVx7I4dwr6ZXQrWKAaTB7x5VwVXlzoUTyMLIVQPLj2gJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"@next/eslint-plugin-next": "16.1.7",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -5483,6 +5500,7 @@
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
@@ -5511,6 +5529,7 @@
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
@@ -6817,6 +6836,7 @@
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
@@ -6826,6 +6846,7 @@
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
@@ -6958,13 +6979,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
||||
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
|
||||
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.1.6",
|
||||
"@next/env": "16.1.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
@@ -6976,14 +6998,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.1.6",
|
||||
"@next/swc-darwin-x64": "16.1.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.6",
|
||||
"@next/swc-linux-arm64-musl": "16.1.6",
|
||||
"@next/swc-linux-x64-gnu": "16.1.6",
|
||||
"@next/swc-linux-x64-musl": "16.1.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.6",
|
||||
"@next/swc-win32-x64-msvc": "16.1.6",
|
||||
"@next/swc-darwin-arm64": "16.1.7",
|
||||
"@next/swc-darwin-x64": "16.1.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.7",
|
||||
"@next/swc-linux-arm64-musl": "16.1.7",
|
||||
"@next/swc-linux-x64-gnu": "16.1.7",
|
||||
"@next/swc-linux-x64-musl": "16.1.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.7",
|
||||
"@next/swc-win32-x64-msvc": "16.1.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -8113,7 +8135,8 @@
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
@@ -8234,6 +8257,7 @@
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
@@ -8258,6 +8282,7 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
@@ -9281,9 +9306,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
@@ -10491,65 +10517,65 @@
|
||||
}
|
||||
},
|
||||
"@next/env": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
|
||||
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg=="
|
||||
},
|
||||
"@next/eslint-plugin-next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
|
||||
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.7.tgz",
|
||||
"integrity": "sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-glob": "3.3.1"
|
||||
}
|
||||
},
|
||||
"@next/swc-darwin-arm64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
|
||||
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
|
||||
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-darwin-x64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
|
||||
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
|
||||
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-linux-arm64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-linux-x64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-linux-x64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-win32-x64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
|
||||
"optional": true
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
@@ -11063,9 +11089,9 @@
|
||||
}
|
||||
},
|
||||
"@vercel/analytics": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.0.tgz",
|
||||
"integrity": "sha512-fP/ASXXz+1K/C2vWTnocd8RsGnkO9f1qOIDrhgQ3DagJtnea1EsM9AV9fDzjXlPIPb2vBQapxOIMCjtGIW8PZw=="
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
|
||||
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="
|
||||
},
|
||||
"acorn": {
|
||||
"version": "8.16.0",
|
||||
@@ -11982,12 +12008,12 @@
|
||||
}
|
||||
},
|
||||
"eslint-config-next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
|
||||
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.7.tgz",
|
||||
"integrity": "sha512-FTq1i/QDltzq+zf9aB/cKWAiZ77baG0V7h8dRQh3thVx7I4dwr6ZXQrWKAaTB7x5VwVXlzoUTyMLIVQPLj2gJg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"@next/eslint-plugin-next": "16.1.7",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -13272,21 +13298,21 @@
|
||||
"dev": true
|
||||
},
|
||||
"next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
||||
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
|
||||
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
|
||||
"requires": {
|
||||
"@next/env": "16.1.6",
|
||||
"@next/swc-darwin-arm64": "16.1.6",
|
||||
"@next/swc-darwin-x64": "16.1.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.6",
|
||||
"@next/swc-linux-arm64-musl": "16.1.6",
|
||||
"@next/swc-linux-x64-gnu": "16.1.6",
|
||||
"@next/swc-linux-x64-musl": "16.1.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.6",
|
||||
"@next/swc-win32-x64-msvc": "16.1.6",
|
||||
"@next/env": "16.1.7",
|
||||
"@next/swc-darwin-arm64": "16.1.7",
|
||||
"@next/swc-darwin-x64": "16.1.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.7",
|
||||
"@next/swc-linux-arm64-musl": "16.1.7",
|
||||
"@next/swc-linux-x64-gnu": "16.1.7",
|
||||
"@next/swc-linux-x64-musl": "16.1.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.7",
|
||||
"@next/swc-win32-x64-msvc": "16.1.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"sharp": "^0.34.4",
|
||||
@@ -14698,9 +14724,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"zustand": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.5.0",
|
||||
"version": "4.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port ${PORT:-3000}",
|
||||
@@ -14,14 +14,14 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@upstash/redis": "^1.37.0",
|
||||
"@vercel/analytics": "^2.0.0",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"next": "16.1.7",
|
||||
"opencc-js": "^1.0.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/next-on-pages": "^1.13.16",
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^10",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-env": "^11.2.0",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Reference in New Issue
Block a user