mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
fix: enforce compliance mode for managed deployments
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
import type { RuntimeFeatures } from '@/lib/config/runtime-features';
|
||||
|
||||
const defaultRuntimeFeatures: RuntimeFeatures = {
|
||||
deploymentProvider: 'self-hosted',
|
||||
deploymentProviderLabel: '自托管',
|
||||
restrictedManagedDeployment: false,
|
||||
mediaProxyEnabled: true,
|
||||
iptvEnabled: true,
|
||||
restrictionSummary: null,
|
||||
};
|
||||
|
||||
const RuntimeFeaturesContext = createContext<RuntimeFeatures>(defaultRuntimeFeatures);
|
||||
|
||||
interface RuntimeFeaturesProviderProps {
|
||||
initialFeatures: RuntimeFeatures;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RuntimeFeaturesProvider({ initialFeatures, children }: RuntimeFeaturesProviderProps) {
|
||||
return (
|
||||
<RuntimeFeaturesContext.Provider value={initialFeatures}>
|
||||
{children}
|
||||
</RuntimeFeaturesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRuntimeFeatures(): RuntimeFeatures {
|
||||
return useContext(RuntimeFeaturesContext);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { siteConfig } from '@/lib/config/site-config';
|
||||
import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -16,11 +17,8 @@ interface NavbarProps {
|
||||
|
||||
export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
const settingsHref = isPremiumMode ? '/premium/settings' : '/settings';
|
||||
const [session, setSessionState] = useState<AuthSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionState(getSession());
|
||||
}, []);
|
||||
const [session] = useState<AuthSession | null>(() => getSession());
|
||||
const { iptvEnabled } = useRuntimeFeatures();
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
@@ -61,7 +59,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* IPTV Link - only show if user has iptv_access or no auth configured */}
|
||||
{hasPermission('iptv_access') && (
|
||||
{iptvEnabled && hasPermission('iptv_access') && (
|
||||
<Link
|
||||
href="/iptv"
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
|
||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||
import { VideoPlayerError } from './VideoPlayerError';
|
||||
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
|
||||
import { usePlayerSettings } from './hooks/usePlayerSettings';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
playUrl: string;
|
||||
@@ -53,38 +52,8 @@ export function VideoPlayer({
|
||||
const durationRef = useRef(0);
|
||||
const SAVE_INTERVAL = 5000; // 5 seconds throttle
|
||||
|
||||
// Get showModeIndicator setting
|
||||
// Get showModeIndicator and proxyMode settings
|
||||
const [showModeIndicator, setShowModeIndicator] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
|
||||
|
||||
useEffect(() => {
|
||||
// Initial value - use mode-specific store
|
||||
const store = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
const settings = store.getSettings();
|
||||
setShowModeIndicator(settings.showModeIndicator);
|
||||
setProxyMode(settings.proxyMode);
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
const newSettings = store.getSettings();
|
||||
setShowModeIndicator(newSettings.showModeIndicator);
|
||||
setProxyMode(newSettings.proxyMode);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Initialize useProxy based on proxyMode when the component mounts or proxyMode changes
|
||||
// We use a separate effect for this to react to setting changes
|
||||
useEffect(() => {
|
||||
if (proxyMode === 'always') {
|
||||
setUseProxy(true);
|
||||
} else if (proxyMode === 'none') {
|
||||
setUseProxy(false);
|
||||
}
|
||||
// For 'retry', we assume it starts as false (direct), which is the default state of useProxy
|
||||
}, [proxyMode]);
|
||||
const { showModeIndicator, proxyMode } = usePlayerSettings(isPremium);
|
||||
const effectiveUseProxy = proxyMode === 'always' ? true : proxyMode === 'none' ? false : useProxy;
|
||||
|
||||
|
||||
// Use reactive hook to subscribe to history updates
|
||||
@@ -174,7 +143,7 @@ export function VideoPlayer({
|
||||
// 3. Proxy mode is 'retry' (specifically for the auto-switch logic)
|
||||
// Note: If mode is 'always', we are already using proxy. If it fails, we show error.
|
||||
|
||||
if (!useProxy && proxyMode === 'retry') {
|
||||
if (!effectiveUseProxy && proxyMode === 'retry') {
|
||||
setUseProxy(true);
|
||||
setShouldAutoPlay(true); // Force autoplay after proxy retry
|
||||
setVideoError('');
|
||||
@@ -200,10 +169,10 @@ export function VideoPlayer({
|
||||
// Let's toggle it to give best chance.
|
||||
// Actually requirement says "try no proxy and proxy and same as before".
|
||||
// So simple toggle is fine.
|
||||
setUseProxy(prev => !prev);
|
||||
setUseProxy(prev => proxyMode === 'none' ? false : !prev);
|
||||
};
|
||||
|
||||
const finalPlayUrl = useProxy || proxyMode === 'always'
|
||||
const finalPlayUrl = effectiveUseProxy
|
||||
? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
|
||||
: playUrl;
|
||||
|
||||
@@ -217,11 +186,11 @@ export function VideoPlayer({
|
||||
{/* Mode Indicator Badge - controlled by settings */}
|
||||
{showModeIndicator && (
|
||||
<div className="absolute top-3 right-3 z-30">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${useProxy
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${effectiveUseProxy
|
||||
? 'bg-orange-500/80 text-white'
|
||||
: 'bg-green-500/80 text-white'
|
||||
}`}>
|
||||
{useProxy ? '代理模式' : '直连模式'}
|
||||
{effectiveUseProxy ? '代理模式' : '直连模式'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -235,7 +204,7 @@ export function VideoPlayer({
|
||||
/>
|
||||
) : (
|
||||
<CustomVideoPlayer
|
||||
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
|
||||
key={`${effectiveUseProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
|
||||
src={finalPlayUrl}
|
||||
onError={handleVideoError}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { usePlayerSettings } from './usePlayerSettings';
|
||||
import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
|
||||
interface UseHlsPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
@@ -22,6 +23,7 @@ export function useHlsPlayer({
|
||||
}: UseHlsPlayerProps) {
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
|
||||
const { mediaProxyEnabled } = useRuntimeFeatures();
|
||||
const isAdFilterEnabled = adFilterMode !== 'off';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,6 +227,9 @@ export function useHlsPlayer({
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.text();
|
||||
} catch (e) {
|
||||
if (!mediaProxyEnabled) {
|
||||
throw e;
|
||||
}
|
||||
console.warn(`[HLS Native] Fetch failed for ${url}, trying proxy...`, e);
|
||||
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
const res = await fetch(proxiedUrl);
|
||||
@@ -397,6 +402,10 @@ export function useHlsPlayer({
|
||||
const handleError = () => {
|
||||
if (directFailed) return;
|
||||
directFailed = true;
|
||||
if (!mediaProxyEnabled) {
|
||||
onError?.('当前浏览器不支持 HLS 视频播放。建议使用 Chrome、Edge 或 Safari 浏览器。');
|
||||
return;
|
||||
}
|
||||
// Try proxied URL as final attempt
|
||||
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(src)}`;
|
||||
video.src = proxiedUrl;
|
||||
@@ -415,5 +424,5 @@ export function useHlsPlayer({
|
||||
}
|
||||
extraBlobs.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords]);
|
||||
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
premiumModeSettingsStore,
|
||||
type ModeSettings,
|
||||
} from '@/lib/store/premium-mode-settings';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
|
||||
interface PlayerSettingsSnapshot {
|
||||
autoNextEpisode: boolean;
|
||||
@@ -30,7 +31,7 @@ interface PlayerSettingsSnapshot {
|
||||
danmakuDisplayArea: number;
|
||||
}
|
||||
|
||||
function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolean): PlayerSettingsSnapshot {
|
||||
const globalSettings = settingsStore.getSettings();
|
||||
const modeSettings = isPremium ? premiumModeSettingsStore.getSettings() : globalSettings;
|
||||
|
||||
@@ -45,7 +46,7 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
adFilterMode: modeSettings.adFilterMode,
|
||||
adKeywords: globalSettings.adKeywords,
|
||||
fullscreenType: modeSettings.fullscreenType,
|
||||
proxyMode: modeSettings.proxyMode,
|
||||
proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none',
|
||||
danmakuEnabled: modeSettings.danmakuEnabled,
|
||||
danmakuApiUrl: modeSettings.danmakuApiUrl,
|
||||
danmakuOpacity: modeSettings.danmakuOpacity,
|
||||
@@ -59,12 +60,13 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
* Provides reactive updates when settings change
|
||||
*/
|
||||
export function usePlayerSettings(isPremium: boolean = false) {
|
||||
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium));
|
||||
const { mediaProxyEnabled } = useRuntimeFeatures();
|
||||
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
|
||||
|
||||
// Subscribe to settings changes
|
||||
useEffect(() => {
|
||||
const syncSettings = () => {
|
||||
setSettings(getPlayerSettingsSnapshot(isPremium));
|
||||
setSettings(getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
|
||||
};
|
||||
|
||||
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
@@ -77,7 +79,7 @@ export function usePlayerSettings(isPremium: boolean = false) {
|
||||
unsubscribeModeStore();
|
||||
unsubscribeGlobalStore?.();
|
||||
};
|
||||
}, [isPremium]);
|
||||
}, [isPremium, mediaProxyEnabled]);
|
||||
|
||||
const updateModeSettings = useCallback((partial: Partial<ModeSettings>) => {
|
||||
if (isPremium) {
|
||||
@@ -145,8 +147,8 @@ export function usePlayerSettings(isPremium: boolean = false) {
|
||||
}, [updateModeSettings]);
|
||||
|
||||
const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => {
|
||||
updateModeSettings({ proxyMode: value });
|
||||
}, [updateModeSettings]);
|
||||
updateModeSettings({ proxyMode: mediaProxyEnabled ? value : 'none' });
|
||||
}, [mediaProxyEnabled, updateModeSettings]);
|
||||
|
||||
const setDanmakuEnabled = useCallback((value: boolean) => {
|
||||
updateModeSettings({ danmakuEnabled: value });
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import {
|
||||
type ProxyMode,
|
||||
DEFAULT_SEEK_STEP_SECONDS,
|
||||
@@ -57,6 +58,9 @@ export function PlayerSettings({
|
||||
onDanmakuDisplayAreaChange,
|
||||
showDanmakuApi = true,
|
||||
}: PlayerSettingsProps) {
|
||||
const { mediaProxyEnabled, restrictionSummary } = useRuntimeFeatures();
|
||||
const effectiveProxyMode = mediaProxyEnabled ? proxyMode : 'none';
|
||||
|
||||
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>
|
||||
@@ -160,38 +164,47 @@ export function PlayerSettings({
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
控制视频播放时的网络请求策略
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => onProxyModeChange('retry')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'retry'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">智能重试 (推荐)</div>
|
||||
<div className="text-sm opacity-80 mt-1">直连优先,失败时尝试代理</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('none')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'none'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">仅直连</div>
|
||||
<div className="text-sm opacity-80 mt-1">不使用代理,失败则报错</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('always')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'always'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">总是代理</div>
|
||||
<div className="text-sm opacity-80 mt-1">所有请求都通过代理转发</div>
|
||||
</button>
|
||||
</div>
|
||||
{mediaProxyEnabled ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => onProxyModeChange('retry')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'retry'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">智能重试 (推荐)</div>
|
||||
<div className="text-sm opacity-80 mt-1">直连优先,失败时尝试代理</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('none')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'none'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">仅直连</div>
|
||||
<div className="text-sm opacity-80 mt-1">不使用代理,失败则报错</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('always')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'always'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">总是代理</div>
|
||||
<div className="text-sm opacity-80 mt-1">所有请求都通过代理转发</div>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-2xl)] border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="font-semibold text-[var(--text-color)]">当前部署仅支持直连播放</div>
|
||||
<div className="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
{restrictionSummary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--glass-border)]" />
|
||||
|
||||
Reference in New Issue
Block a user