mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: add premium password protection and enhance search functionality
- Implemented a separate password for premium content access via `PREMIUM_PASSWORD` environment variable. - Added a `PremiumPasswordGate` component to manage access to premium content. - Enhanced search functionality to support Traditional Chinese to Simplified Chinese conversion for broader search compatibility. - Introduced automatic video quality labels in search results for better user experience. - Added support for IPTV sources configuration through environment variables. - Enabled merging of sources in search results based on environment variable settings. - Updated volume control logic to handle mute state more effectively in video playback. - Improved README documentation to reflect new features and configurations.
This commit is contained in:
@@ -55,6 +55,8 @@
|
||||
- **搜索历史**:自动保存搜索历史,支持快速重新搜索
|
||||
- **搜索结果显示**:支持默认显示和合并同名源两种模式
|
||||
- **实时延迟监测**:可选实时显示各源的网络延迟
|
||||
- **清晰度标签**:自动解析并显示视频清晰度(4K/蓝光/1080P/720P/HD 等),方便快速分辨源质量
|
||||
- **繁体中文搜索**:自动将繁体中文转换为简体中文进行搜索,确保繁体输入也能搜到结果
|
||||
- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示,智能合并同名分类标签,展开/折叠状态持久化记忆
|
||||
- **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签
|
||||
|
||||
@@ -281,7 +283,27 @@ docker run -d -p 3000:3000 \
|
||||
|
||||
这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。
|
||||
|
||||
### 方式三:会话持久化设置
|
||||
### 方式三:高级内容独立密码
|
||||
|
||||
通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。
|
||||
|
||||
适合场景:给家人分享普通密码,但高级内容需要额外密码才能访问。
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker run -d -p 3000:3000 \
|
||||
-e ADMIN_PASSWORD="admin123" \
|
||||
-e PREMIUM_PASSWORD="premium456" \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
**特点:**
|
||||
- 访问 `/premium` 页面时需输入此专用密码
|
||||
- 管理员密码和 admin/super_admin 账号也可以解锁高级内容
|
||||
- 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入
|
||||
- 不设置此变量时,高级内容无额外密码保护
|
||||
|
||||
### 方式四:会话持久化设置
|
||||
|
||||
通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话:
|
||||
|
||||
@@ -415,6 +437,59 @@ docker run -d -p 3000:3000 \
|
||||
|
||||
> 用户还可以在设置页面的「弹幕 API」区域添加多个 API 端点并选择当前使用的,用户选择的 API 优先于系统默认配置。
|
||||
|
||||
## IPTV 直播源配置
|
||||
|
||||
通过环境变量预设 IPTV 直播源,应用启动时会自动添加到直播源列表中。
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `IPTV_SOURCES` | IPTV 直播源配置(服务端) | - |
|
||||
| `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置(客户端) | - |
|
||||
|
||||
**格式:** JSON 数组字符串,包含 `name` 和 `url` 字段;或直接提供 M3U 链接(逗号分隔多个)。
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# JSON 格式
|
||||
IPTV_SOURCES='[{"name":"央视","url":"https://example.com/cctv.m3u"},{"name":"地方台","url":"https://example.com/local.m3u"}]'
|
||||
|
||||
# 简单 URL 格式
|
||||
IPTV_SOURCES='https://example.com/cctv.m3u,https://example.com/local.m3u'
|
||||
```
|
||||
|
||||
## 合并同名源配置
|
||||
|
||||
通过环境变量设置默认启用搜索结果的合并同名源显示模式。
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `MERGE_SOURCES` | 启用合并同名源(`true` 或 `1`) | - |
|
||||
| `NEXT_PUBLIC_MERGE_SOURCES` | 启用合并同名源(客户端) | - |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
MERGE_SOURCES=true
|
||||
```
|
||||
|
||||
设置后搜索结果会自动以合并模式显示,将来自不同源的同名视频合并为一个卡片。用户仍可在设置页面中手动切换显示模式。
|
||||
|
||||
## 自定义端口
|
||||
|
||||
通过 `PORT` 环境变量自定义应用端口,默认为 3000。
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
PORT=8080 npm run dev
|
||||
|
||||
# 生产模式
|
||||
PORT=8080 npm run start
|
||||
|
||||
# Docker
|
||||
docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
## 自定义源 JSON 格式
|
||||
|
||||
如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。
|
||||
@@ -488,12 +563,16 @@ docker run -d -p 3000:3000 \
|
||||
| `ADMIN_PASSWORD` | 管理员密码 | - |
|
||||
| `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`) | - |
|
||||
| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - |
|
||||
| `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - |
|
||||
| `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
|
||||
| `PORT` | 自定义应用端口 | `3000` |
|
||||
| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
|
||||
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
|
||||
| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
|
||||
| `SUBSCRIPTION_SOURCES` | 自动订阅源配置(服务端) | - |
|
||||
| `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - |
|
||||
| `IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置 | - |
|
||||
| `MERGE_SOURCES` / `NEXT_PUBLIC_MERGE_SOURCES` | 启用合并同名源显示(`true`/`1`) | - |
|
||||
| `AD_KEYWORDS` / `NEXT_PUBLIC_AD_KEYWORDS` | 广告过滤关键词 | - |
|
||||
| `AD_KEYWORDS_FILE` | 广告关键词文件路径 | - |
|
||||
| `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - |
|
||||
@@ -605,10 +684,13 @@ docker-compose up -d
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e ADMIN_PASSWORD="admin123" \
|
||||
-e PREMIUM_PASSWORD="premium456" \
|
||||
-e ACCOUNTS="user1:用户一:admin,user2:用户二:viewer:iptv_access" \
|
||||
-e NEXT_PUBLIC_SITE_NAME="我的视频" \
|
||||
-e NEXT_PUBLIC_DANMAKU_API_URL="https://danmaku.example.com" \
|
||||
-e SUBSCRIPTION_SOURCES='[{"name":"默认源","url":"https://example.com/sources.json"}]' \
|
||||
-e IPTV_SOURCES='[{"name":"央视","url":"https://example.com/cctv.m3u"}]' \
|
||||
-e MERGE_SOURCES=true \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
|
||||
+30
-1
@@ -10,8 +10,11 @@ export const runtime = 'edge';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const ACCOUNTS = process.env.ACCOUNTS || '';
|
||||
const PREMIUM_PASSWORD = process.env.PREMIUM_PASSWORD || '';
|
||||
const PERSIST_SESSION = process.env.PERSIST_SESSION !== 'false'; // default true
|
||||
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
|
||||
const IPTV_SOURCES = process.env.IPTV_SOURCES || process.env.NEXT_PUBLIC_IPTV_SOURCES || '';
|
||||
const MERGE_SOURCES = process.env.MERGE_SOURCES || process.env.NEXT_PUBLIC_MERGE_SOURCES || '';
|
||||
|
||||
// Backward compat: ACCESS_PASSWORD acts as ADMIN_PASSWORD if ADMIN_PASSWORD is not set
|
||||
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
@@ -65,19 +68,45 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({
|
||||
hasAuth,
|
||||
hasPremiumAuth: !!PREMIUM_PASSWORD,
|
||||
persistSession: PERSIST_SESSION,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
iptvSources: IPTV_SOURCES,
|
||||
mergeSources: MERGE_SOURCES,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { password } = await request.json();
|
||||
const { password, type } = await request.json();
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Premium password check (separate from main auth)
|
||||
if (type === 'premium') {
|
||||
if (!PREMIUM_PASSWORD) {
|
||||
// No premium password configured = open access
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
if (password === PREMIUM_PASSWORD) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
// Also allow admin password to unlock premium
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
// Check ACCOUNTS super_admin/admin
|
||||
const accounts = parseAccounts();
|
||||
for (const account of accounts) {
|
||||
if (password === account.password && (account.role === 'super_admin' || account.role === 'admin')) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ valid: false });
|
||||
}
|
||||
|
||||
// 1. Check admin password
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
const profileId = await generateProfileId(password);
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
|
||||
@@ -30,6 +31,9 @@ export async function POST(request: NextRequest) {
|
||||
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
|
||||
@@ -63,7 +67,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
// Search page 1 for this source
|
||||
const result = await searchVideos(query.trim(), [source], 1);
|
||||
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 videos = result[0]?.results || [];
|
||||
@@ -101,7 +105,7 @@ export async function POST(request: NextRequest) {
|
||||
const remainingPages = Array.from({ length: pagecount - 1 }, (_, i) => i + 2);
|
||||
const pagePromises = remainingPages.map(async (pg) => {
|
||||
try {
|
||||
const pageResult = await searchVideos(query.trim(), [source], pg);
|
||||
const pageResult = await searchVideos(normalizedQuery, [source], pg);
|
||||
const pageVideos = pageResult[0]?.results || [];
|
||||
|
||||
totalVideosFound += pageVideos.length;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SearchResults } from '@/components/home/SearchResults';
|
||||
import { usePremiumHomePage } from '@/lib/hooks/usePremiumHomePage';
|
||||
import { PremiumContent } from '@/components/premium/PremiumContent';
|
||||
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
|
||||
import { PremiumPasswordGate } from '@/components/PremiumPasswordGate';
|
||||
|
||||
function PremiumHomePage() {
|
||||
const {
|
||||
@@ -83,7 +84,9 @@ export default function PremiumPage() {
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
|
||||
</div>
|
||||
}>
|
||||
<PremiumHomePage />
|
||||
<PremiumPasswordGate>
|
||||
<PremiumHomePage />
|
||||
</PremiumPasswordGate>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,61 @@ import { useState, useEffect } from 'react';
|
||||
import { getSession, setSession } from '@/lib/store/auth-store';
|
||||
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { useIPTVStore } from '@/lib/store/iptv-store';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Sync IPTV sources from environment variable.
|
||||
* Format: JSON array [{name, url}] or comma-separated URLs.
|
||||
*/
|
||||
function syncIPTVSources(rawValue: string) {
|
||||
const iptvStore = useIPTVStore.getState();
|
||||
const existingUrls = new Set(iptvStore.sources.map(s => s.url));
|
||||
|
||||
let entries: { name: string; url: string }[] = [];
|
||||
|
||||
// Try JSON
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (Array.isArray(parsed)) {
|
||||
entries = parsed.filter((item: any) => item && typeof item.url === 'string');
|
||||
}
|
||||
} catch {
|
||||
// Try comma-separated URLs
|
||||
if (rawValue.includes('http')) {
|
||||
const urls = rawValue.split(',').map(u => u.trim()).filter(u => u.startsWith('http'));
|
||||
entries = urls.map((url, i) => ({
|
||||
name: urls.length > 1 ? `直播源 ${i + 1}` : '直播源',
|
||||
url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Add new sources that don't already exist
|
||||
for (const entry of entries) {
|
||||
if (!existingUrls.has(entry.url)) {
|
||||
iptvStore.addSource(entry.name || '直播源', entry.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync merge sources setting from environment variable.
|
||||
* Value: 'true' or '1' to enable grouped display mode.
|
||||
*/
|
||||
function syncMergeSources(rawValue: string) {
|
||||
const enabled = rawValue === 'true' || rawValue === '1';
|
||||
if (!enabled) return;
|
||||
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.searchDisplayMode !== 'grouped') {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
searchDisplayMode: 'grouped',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function PasswordGate({ children, hasAuth: initialHasAuth }: { children: React.ReactNode, hasAuth: boolean }) {
|
||||
// Enable background subscription syncing globally
|
||||
useSubscriptionSync();
|
||||
@@ -49,6 +102,16 @@ export function PasswordGate({ children, hasAuth: initialHasAuth }: { children:
|
||||
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
|
||||
}
|
||||
|
||||
// Sync IPTV sources from env
|
||||
if (data.iptvSources) {
|
||||
syncIPTVSources(data.iptvSources);
|
||||
}
|
||||
|
||||
// Sync merge sources setting from env
|
||||
if (data.mergeSources) {
|
||||
syncMergeSources(data.mergeSources);
|
||||
}
|
||||
|
||||
// Re-evaluate lock status with confirmed server state
|
||||
const confirmLocked = data.hasAuth && !isAuthenticated;
|
||||
setIsLocked(confirmLocked);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
const PREMIUM_UNLOCK_KEY = 'kvideo-premium-unlocked';
|
||||
|
||||
export function PremiumPasswordGate({ children }: { children: React.ReactNode }) {
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [hasPremiumAuth, setHasPremiumAuth] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const init = async () => {
|
||||
// Check if already unlocked in this session
|
||||
const unlocked = sessionStorage.getItem(PREMIUM_UNLOCK_KEY) === 'true';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth');
|
||||
if (!res.ok) throw new Error('Failed to fetch auth config');
|
||||
const data = await res.json();
|
||||
|
||||
if (mounted) {
|
||||
setHasPremiumAuth(data.hasPremiumAuth);
|
||||
// If no premium password configured, allow access
|
||||
setIsLocked(data.hasPremiumAuth && !unlocked);
|
||||
setIsClient(true);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
const handleUnlock = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password, type: 'premium' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.valid) {
|
||||
sessionStorage.setItem(PREMIUM_UNLOCK_KEY, 'true');
|
||||
setIsLocked(false);
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// API error
|
||||
}
|
||||
|
||||
setError(true);
|
||||
setIsValidating(false);
|
||||
const form = document.getElementById('premium-password-form');
|
||||
form?.classList.add('animate-shake');
|
||||
setTimeout(() => form?.classList.remove('animate-shake'), 500);
|
||||
};
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black text-white">
|
||||
<div className="w-full max-w-md p-4">
|
||||
<form
|
||||
id="premium-password-form"
|
||||
onSubmit={handleUnlock}
|
||||
className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-amber-500/10 flex items-center justify-center text-amber-500 mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
|
||||
<Lock size={32} />
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold">高级内容</h2>
|
||||
<p className="text-[var(--text-color-secondary)]">请输入高级内容密码以继续</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(false);
|
||||
}}
|
||||
placeholder="输入高级内容密码..."
|
||||
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'
|
||||
} focus:outline-none focus:border-amber-500 focus:shadow-[0_0_0_3px_rgba(245,158,11,0.3)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-white placeholder-gray-500`}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center animate-pulse">
|
||||
密码错误
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isValidating}
|
||||
className="w-full py-3 px-4 bg-amber-500 text-black font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isValidating ? '验证中...' : '解锁'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.3s cubic-bezier(.36,.07,.19,.97) both;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,8 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
const timeDiff = Math.abs(currentTime - lastTimeRef.current);
|
||||
if (timeDiff > 2) {
|
||||
activeRef.current = [];
|
||||
lastSpawnTimeRef.current = -1;
|
||||
// Set to currentTime so only comments from the new position forward are spawned
|
||||
lastSpawnTimeRef.current = currentTime;
|
||||
laneSlotsRef.current = new Array(MAX_LANES).fill(0);
|
||||
}
|
||||
lastTimeRef.current = currentTime;
|
||||
|
||||
@@ -87,7 +87,10 @@ export function useDesktopShortcuts({
|
||||
e.preventDefault();
|
||||
const newVolUp = Math.min(1, volume + 0.1);
|
||||
setVolume(newVolUp);
|
||||
if (videoRef.current) videoRef.current.volume = newVolUp;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolUp;
|
||||
videoRef.current.muted = newVolUp === 0;
|
||||
}
|
||||
setIsMuted(newVolUp === 0);
|
||||
localStorage.setItem('kvideo-volume', String(newVolUp));
|
||||
localStorage.setItem('kvideo-muted', String(newVolUp === 0));
|
||||
@@ -97,7 +100,10 @@ export function useDesktopShortcuts({
|
||||
e.preventDefault();
|
||||
const newVolDown = Math.max(0, volume - 0.1);
|
||||
setVolume(newVolDown);
|
||||
if (videoRef.current) videoRef.current.volume = newVolDown;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolDown;
|
||||
videoRef.current.muted = newVolDown === 0;
|
||||
}
|
||||
setIsMuted(newVolDown === 0);
|
||||
localStorage.setItem('kvideo-volume', String(newVolDown));
|
||||
localStorage.setItem('kvideo-muted', String(newVolDown === 0));
|
||||
|
||||
@@ -93,6 +93,7 @@ export function usePlaybackControls({
|
||||
|
||||
// Apply saved volume and mute state when new source loads
|
||||
videoRef.current.volume = isMuted ? 0 : volume;
|
||||
videoRef.current.muted = isMuted;
|
||||
|
||||
videoRef.current.play().catch((err: Error) => {
|
||||
console.warn('Autoplay was prevented:', err);
|
||||
|
||||
@@ -26,11 +26,12 @@ export function useVolumeControls({
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
if (isMuted) {
|
||||
videoRef.current.volume = volume;
|
||||
videoRef.current.muted = false;
|
||||
videoRef.current.volume = volume || 0.5;
|
||||
setIsMuted(false);
|
||||
localStorage.setItem('kvideo-muted', 'false');
|
||||
} else {
|
||||
videoRef.current.volume = 0;
|
||||
videoRef.current.muted = true;
|
||||
setIsMuted(true);
|
||||
localStorage.setItem('kvideo-muted', 'true');
|
||||
}
|
||||
@@ -52,6 +53,7 @@ export function useVolumeControls({
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
setVolume(pos);
|
||||
videoRef.current.volume = pos;
|
||||
videoRef.current.muted = pos === 0;
|
||||
setIsMuted(pos === 0);
|
||||
localStorage.setItem('kvideo-volume', String(pos));
|
||||
localStorage.setItem('kvideo-muted', String(pos === 0));
|
||||
@@ -71,6 +73,7 @@ export function useVolumeControls({
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
setVolume(pos);
|
||||
videoRef.current.volume = pos;
|
||||
videoRef.current.muted = pos === 0;
|
||||
setIsMuted(pos === 0);
|
||||
localStorage.setItem('kvideo-volume', String(pos));
|
||||
localStorage.setItem('kvideo-muted', String(pos === 0));
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
||||
|
||||
import { Video } from '@/lib/types';
|
||||
import { parseVideoTitle } from '@/lib/utils/video';
|
||||
import { parseVideoTitle, extractQualityLabel } from '@/lib/utils/video';
|
||||
|
||||
interface VideoCardProps {
|
||||
video: Video;
|
||||
@@ -160,17 +160,25 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
const { cleanTitle, quality } = parseVideoTitle(video.vod_name);
|
||||
// Visual priority: Quality from title tag, then vod_remarks
|
||||
const displayQuality = quality || video.vod_remarks;
|
||||
const qualityBadge = extractQualityLabel(video.vod_remarks, quality);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] mb-1">
|
||||
{cleanTitle}
|
||||
</h4>
|
||||
{displayQuality && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] font-medium">
|
||||
{displayQuality}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{qualityBadge && (
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${qualityBadge.color}`}>
|
||||
{qualityBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{displayQuality && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] font-medium truncate">
|
||||
{displayQuality}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Hide remarks if it was used as quality to avoid duplication */}
|
||||
{video.vod_remarks && video.vod_remarks !== displayQuality && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Traditional Chinese to Simplified Chinese conversion table.
|
||||
* Contains common character mappings for search purposes.
|
||||
*/
|
||||
|
||||
const T2S_MAP: Record<string, string> = {
|
||||
'愛': '爱', '礙': '碍', '闇': '暗', '罷': '罢', '備': '备', '貝': '贝', '筆': '笔', '畢': '毕', '邊': '边',
|
||||
'變': '变', '標': '标', '錶': '表', '別': '别', '賓': '宾', '補': '补', '佈': '布', '參': '参', '殘': '残',
|
||||
'蠶': '蚕', '倉': '仓', '層': '层', '產': '产', '長': '长', '場': '场', '廠': '厂', '車': '车', '陳': '陈',
|
||||
'稱': '称', '誠': '诚', '遲': '迟', '衝': '冲', '醜': '丑', '處': '处', '觸': '触', '辭': '辞', '從': '从',
|
||||
'達': '达', '帶': '带', '當': '当', '黨': '党', '導': '导', '燈': '灯', '敵': '敌', '點': '点', '電': '电',
|
||||
'東': '东', '動': '动', '獨': '独', '對': '对', '噸': '吨', '奪': '夺', '發': '发', '範': '范', '飛': '飞',
|
||||
'費': '费', '奮': '奋', '豐': '丰', '風': '风', '鳳': '凤', '復': '复', '負': '负', '蓋': '盖', '幹': '干',
|
||||
'鋼': '钢', '個': '个', '給': '给', '鞏': '巩', '貢': '贡', '構': '构', '購': '购', '穀': '谷', '顧': '顾',
|
||||
'關': '关', '觀': '观', '廣': '广', '歸': '归', '龜': '龟', '國': '国', '過': '过', '華': '华', '畫': '画',
|
||||
'話': '话', '壞': '坏', '歡': '欢', '環': '环', '還': '还', '匯': '汇', '會': '会', '護': '护', '壺': '壶',
|
||||
'滬': '沪', '劃': '划', '懷': '怀', '換': '换', '黃': '黄', '繪': '绘',
|
||||
'惠': '惠', '獲': '获', '機': '机', '積': '积', '極': '极', '際': '际', '濟': '济', '計': '计', '記': '记',
|
||||
'繼': '继', '紀': '纪', '夾': '夹', '價': '价', '駕': '驾', '堅': '坚', '間': '间', '簡': '简',
|
||||
'見': '见', '劍': '剑', '漸': '渐', '將': '将', '獎': '奖', '講': '讲', '醬': '酱', '節': '节', '潔': '洁',
|
||||
'結': '结', '進': '进', '盡': '尽', '經': '经', '驚': '惊', '競': '竞', '舊': '旧', '據': '据', '劇': '剧',
|
||||
'軍': '军', '開': '开', '凱': '凯', '殼': '壳', '課': '课', '懇': '恳', '誇': '夸', '塊': '块', '寬': '宽',
|
||||
'況': '况', '礦': '矿', '虧': '亏', '來': '来', '賴': '赖', '藍': '蓝', '蘭': '兰', '攔': '拦', '覽': '览',
|
||||
'懶': '懒', '爛': '烂', '撈': '捞', '勞': '劳', '樂': '乐', '類': '类', '離': '离', '裡': '里', '禮': '礼',
|
||||
'歷': '历', '勵': '励', '聯': '联', '練': '练', '糧': '粮', '兩': '两', '遼': '辽', '療': '疗', '獵': '猎',
|
||||
'臨': '临', '鄰': '邻', '靈': '灵', '領': '领', '劉': '刘', '龍': '龙', '樓': '楼', '陸': '陆', '錄': '录',
|
||||
'慮': '虑', '輪': '轮', '論': '论', '羅': '罗', '駱': '骆', '馬': '马', '買': '买', '賣': '卖', '麥': '麦',
|
||||
'滿': '满', '貓': '猫', '貿': '贸', '門': '门', '們': '们', '夢': '梦', '滅': '灭', '廟': '庙',
|
||||
'難': '难', '腦': '脑', '鬧': '闹', '釀': '酿', '鳥': '鸟', '聶': '聂', '寧': '宁', '農': '农', '歐': '欧',
|
||||
'盤': '盘', '龐': '庞', '賠': '赔', '噴': '喷', '鵬': '鹏', '騙': '骗', '蘋': '苹', '評': '评', '鋪': '铺',
|
||||
'樸': '朴', '齊': '齐', '氣': '气', '遷': '迁', '錢': '钱', '潛': '潜', '淺': '浅', '搶': '抢', '親': '亲',
|
||||
'輕': '轻', '請': '请', '慶': '庆', '瓊': '琼', '區': '区', '權': '权', '勸': '劝', '確': '确', '讓': '让',
|
||||
'擾': '扰', '熱': '热', '認': '认', '榮': '荣', '軟': '软', '銳': '锐', '賽': '赛', '傘': '伞', '喪': '丧',
|
||||
'殺': '杀', '曬': '晒', '傷': '伤', '賞': '赏', '燒': '烧', '設': '设', '審': '审', '聲': '声', '勝': '胜',
|
||||
'師': '师', '實': '实', '時': '时', '識': '识', '適': '适', '勢': '势', '釋': '释', '壽': '寿', '書': '书',
|
||||
'術': '术', '樹': '树', '雙': '双', '誰': '谁', '順': '顺', '說': '说', '絲': '丝', '鬆': '松', '蘇': '苏',
|
||||
'隨': '随', '歲': '岁', '損': '损', '鎖': '锁', '態': '态', '談': '谈', '嘆': '叹', '湯': '汤', '條': '条',
|
||||
'鐵': '铁', '聽': '听', '統': '统', '圖': '图', '團': '团', '萬': '万', '網': '网', '衛': '卫', '穩': '稳',
|
||||
'問': '问', '無': '无', '務': '务', '霧': '雾', '習': '习', '戲': '戏', '細': '细', '蝦': '虾', '險': '险',
|
||||
'現': '现', '獻': '献', '鄉': '乡', '響': '响', '項': '项', '協': '协', '寫': '写', '興': '兴', '選': '选',
|
||||
'學': '学', '壓': '压', '鴨': '鸭', '鹽': '盐', '嚴': '严', '顏': '颜', '陽': '阳', '養': '养', '樣': '样',
|
||||
'搖': '摇', '葉': '叶', '業': '业', '醫': '医', '義': '义', '藝': '艺', '億': '亿', '議': '议', '陰': '阴',
|
||||
'應': '应', '營': '营', '優': '优', '郵': '邮', '與': '与', '語': '语', '預': '预', '員': '员', '園': '园',
|
||||
'遠': '远', '願': '愿', '閱': '阅', '運': '运', '雜': '杂', '災': '灾', '贊': '赞', '髒': '脏', '棗': '枣',
|
||||
'責': '责', '戰': '战', '張': '张', '趙': '赵', '鎮': '镇', '爭': '争', '鄭': '郑', '證': '证', '織': '织',
|
||||
'質': '质', '種': '种', '眾': '众', '鑄': '铸', '專': '专', '莊': '庄', '裝': '装', '準': '准', '資': '资',
|
||||
'總': '总', '組': '组', '鑽': '钻', '嘗': '尝', '遞': '递', '豬': '猪', '傳': '传', '雲': '云',
|
||||
'訂': '订', '檔': '档', '調': '调', '詞': '词', '釘': '钉', '鍛': '锻', '隊': '队', '噁': '恶', '額': '额',
|
||||
'複': '复', '鍋': '锅', '號': '号', '鷄': '鸡', '幾': '几', '驗': '验', '薑': '姜', '膠': '胶', '階': '阶',
|
||||
'腳': '脚', '較': '较', '僅': '仅', '緊': '紧', '決': '决', '絕': '绝', '覺': '觉', '擴': '扩', '闊': '阔',
|
||||
'蠟': '蜡', '麗': '丽', '鏈': '链', '憐': '怜', '嶺': '岭', '媽': '妈', '瑪': '玛', '罵': '骂', '邁': '迈',
|
||||
'麼': '么', '黴': '霉', '謎': '谜', '鳴': '鸣', '謀': '谋', '納': '纳', '惱': '恼', '擬': '拟', '紐': '纽',
|
||||
'濃': '浓', '瘧': '疟', '拋': '抛', '貧': '贫', '頻': '频', '憑': '凭', '籤': '签', '強': '强', '竊': '窃',
|
||||
'傾': '倾', '窮': '穷', '趨': '趋', '繞': '绕', '紉': '纫', '鏽': '锈',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Traditional Chinese characters to Simplified Chinese.
|
||||
* Characters not in the mapping are left unchanged.
|
||||
*/
|
||||
export function traditionalToSimplified(text: string): string {
|
||||
let result = '';
|
||||
for (const char of text) {
|
||||
result += T2S_MAP[char] || char;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any Traditional Chinese characters.
|
||||
*/
|
||||
export function hasTraditionalChinese(text: string): boolean {
|
||||
for (const char of text) {
|
||||
if (T2S_MAP[char]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+32
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Parses a video title to extract quality tags (e.g., [HD], [TS])
|
||||
* Parses a video title to extract quality tags (e.g., [HD], [TS])
|
||||
* and return a cleaned title.
|
||||
*/
|
||||
export function parseVideoTitle(title: string): { cleanTitle: string, quality?: string } {
|
||||
@@ -25,3 +25,34 @@ export function parseVideoTitle(title: string): { cleanTitle: string, quality?:
|
||||
quality
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality keywords and their display labels, ordered by priority (highest first).
|
||||
*/
|
||||
const QUALITY_PATTERNS: { pattern: RegExp; label: string; color: string }[] = [
|
||||
{ pattern: /4k|2160p/i, label: '4K', color: 'bg-amber-500' },
|
||||
{ pattern: /蓝光|藍光|bluray|blu-ray/i, label: '蓝光', color: 'bg-blue-500' },
|
||||
{ pattern: /1080p|1080i|full\s*hd|fhd/i, label: '1080P', color: 'bg-green-500' },
|
||||
{ pattern: /超清|超高清/i, label: '超清', color: 'bg-green-500' },
|
||||
{ pattern: /720p|hd720/i, label: '720P', color: 'bg-teal-500' },
|
||||
{ pattern: /\bhd\b|高清/i, label: 'HD', color: 'bg-teal-500' },
|
||||
{ pattern: /抢先|枪版|ts版|ts\b|cam\b|hdts/i, label: 'TS', color: 'bg-orange-500' },
|
||||
{ pattern: /标清|sd\b/i, label: 'SD', color: 'bg-gray-500' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Extracts quality label from video remarks or title.
|
||||
* Returns the quality label and its associated color class.
|
||||
*/
|
||||
export function extractQualityLabel(remarks?: string, quality?: string): { label: string; color: string } | null {
|
||||
const text = `${remarks || ''} ${quality || ''}`;
|
||||
if (!text.trim()) return null;
|
||||
|
||||
for (const { pattern, label, color } of QUALITY_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return { label, color };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,9 +3,9 @@
|
||||
"version": "4.4.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --port ${PORT:-3000}",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "next start --port ${PORT:-3000}",
|
||||
"lint": "eslint",
|
||||
"pages:build": "npx @cloudflare/next-on-pages@1"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user