mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 12:13: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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user