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:
kuekhaoyang
2026-03-01 12:06:53 +08:00
parent 64b35b2fbd
commit c95d7d5d95
14 changed files with 468 additions and 19 deletions
+63
View File
@@ -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);
+141
View File
@@ -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>
);
}
+2 -1
View File
@@ -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));
+14 -6
View File
@@ -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">