diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index 7f849fc..a9039b3 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -34,9 +34,12 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } }; // 1. Initial local check (fast) + // Determine if the app SHOULD be protected + const isProtected = (settings.passwordAccess && settings.accessPasswords.length > 0) || initialHasEnvPassword; + // Default to canPersist = true for first check if not sure const isUnlocked = getUnlockedState(true); - const localLocked = (settings.passwordAccess || initialHasEnvPassword) && !isUnlocked; + const localLocked = isProtected && !isUnlocked; if (mounted) setIsLocked(localLocked); if (mounted) setIsClient(true); @@ -62,7 +65,8 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } const canPersist = data.hasEnvPassword && data.persistPassword; const finalUnlocked = getUnlockedState(canPersist); - const confirmLocked = (settings.passwordAccess || data.hasEnvPassword) && !finalUnlocked; + const isProtectedNow = (settings.passwordAccess && settings.accessPasswords.length > 0) || data.hasEnvPassword; + const confirmLocked = isProtectedNow && !finalUnlocked; setIsLocked(confirmLocked); } } catch (e) { @@ -85,9 +89,11 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } const isUnlocked = (sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true') || (canPersist && localStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'); - if (!settings.passwordAccess && !hasEnvPassword) { + const isProtected = (settings.passwordAccess && settings.accessPasswords.length > 0) || hasEnvPassword; + + if (!isProtected) { setIsLocked(false); - } else if (settings.passwordAccess && !isUnlocked) { + } else if (!isUnlocked) { setIsLocked(true); } }; diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index e7f5571..3558a41 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -8,6 +8,8 @@ import { useAutoSkip } from './hooks/useAutoSkip'; import { useStallDetection } from './hooks/useStallDetection'; import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper'; import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper'; +import { usePlayerSettings } from './hooks/usePlayerSettings'; +import './web-fullscreen.css'; interface DesktopVideoPlayerProps { src: string; @@ -36,6 +38,7 @@ export function DesktopVideoPlayer({ isReversed = false, }: DesktopVideoPlayerProps) { const { refs, data, actions } = useDesktopPlayerState(); + const { fullscreenType } = usePlayerSettings(); // Initialize HLS Player useHlsPlayer({ @@ -75,7 +78,8 @@ export function DesktopVideoPlayer({ onTimeUpdate, refs, data, - actions + actions, + fullscreenType }); // Auto-skip intro/outro and auto-next episode @@ -113,7 +117,8 @@ export function DesktopVideoPlayer({ return (
isPlaying && setShowControls(false)} > diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx index cea8e69..4baa479 100644 --- a/components/player/desktop/DesktopMoreMenu.tsx +++ b/components/player/desktop/DesktopMoreMenu.tsx @@ -43,6 +43,8 @@ export function DesktopMoreMenu({ setAdFilter, adFilterMode, setAdFilterMode, + fullscreenType, + setFullscreenType, } = usePlayerSettings(); const buttonRef = React.useRef(null); @@ -269,6 +271,44 @@ export function DesktopMoreMenu({ )}
+ {/* Fullscreen Mode Selector */} +
+
+ + 全屏方式 +
+
+ +
+
+ + {/* Fullscreen Mode Selector */} +
+
+ + 全屏方式 +
+
+ +
+
+ {/* Skip Outro Switch */}
diff --git a/components/player/hooks/desktop/useFullscreenControls.ts b/components/player/hooks/desktop/useFullscreenControls.ts index a834a91..2a6acf7 100644 --- a/components/player/hooks/desktop/useFullscreenControls.ts +++ b/components/player/hooks/desktop/useFullscreenControls.ts @@ -9,6 +9,7 @@ interface UseFullscreenControlsProps { isAirPlaySupported: boolean; setIsPiPSupported: (supported: boolean) => void; setIsAirPlaySupported: (supported: boolean) => void; + fullscreenType?: 'native' | 'window'; } export function useFullscreenControls({ @@ -19,7 +20,8 @@ export function useFullscreenControls({ isPiPSupported, isAirPlaySupported, setIsPiPSupported, - setIsAirPlaySupported + setIsAirPlaySupported, + fullscreenType = 'native' }: UseFullscreenControlsProps) { useEffect(() => { if (typeof document !== 'undefined') { @@ -34,6 +36,11 @@ export function useFullscreenControls({ if (!containerRef.current) return; if (!isFullscreen) { + if (fullscreenType === 'window') { + setIsFullscreen(true); + return; + } + try { if (containerRef.current.requestFullscreen) { await containerRef.current.requestFullscreen(); @@ -44,11 +51,9 @@ export function useFullscreenControls({ } else if ((containerRef.current as any).msRequestFullscreen) { await (containerRef.current as any).msRequestFullscreen(); } else if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { - // Fallback for browsers that only support fullscreen on video element (like some car browsers) (videoRef.current as any).webkitEnterFullscreen(); } - // Lock orientation to landscape on mobile devices if supported if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.lock) { try { await (window.screen as any).orientation.lock('landscape'); @@ -58,7 +63,6 @@ export function useFullscreenControls({ } } catch (error) { console.warn('Fullscreen request failed, trying fallback:', error); - // Last ditch effort: try native video fullscreen if container failed if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { try { (videoRef.current as any).webkitEnterFullscreen(); @@ -68,6 +72,11 @@ export function useFullscreenControls({ } } } else { + if (fullscreenType === 'window') { + setIsFullscreen(false); + return; + } + try { if (document.exitFullscreen) { await document.exitFullscreen(); @@ -79,7 +88,6 @@ export function useFullscreenControls({ await (document as any).msExitFullscreen(); } - // Unlock orientation when exiting fullscreen if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.unlock) { try { (window.screen as any).orientation.unlock(); @@ -91,7 +99,7 @@ export function useFullscreenControls({ console.error('Failed to exit fullscreen:', error); } } - }, [containerRef, videoRef, isFullscreen]); + }, [containerRef, videoRef, isFullscreen, fullscreenType, setIsFullscreen]); useEffect(() => { const handleFullscreenChange = () => { @@ -101,9 +109,12 @@ export function useFullscreenControls({ (document as any).mozFullScreenElement || (document as any).msFullscreenElement ); - setIsFullscreen(isInFullscreen); - // Double check orientation lock/unlock on change + // Only update if not in window mode, or if exiting native mode + if (fullscreenType === 'native' || !isInFullscreen) { + setIsFullscreen(isInFullscreen); + } + if (isInFullscreen) { if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.lock) { (window.screen as any).orientation.lock('landscape').catch(() => { }); @@ -128,7 +139,19 @@ export function useFullscreenControls({ document.removeEventListener('mozfullscreenchange', handleFullscreenChange); document.removeEventListener('MSFullscreenChange', handleFullscreenChange); }; - }, [setIsFullscreen]); + }, [setIsFullscreen, fullscreenType]); + + useEffect(() => { + if (isFullscreen && fullscreenType === 'window') { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsFullscreen(false); + } + }; + window.addEventListener('keydown', handleEsc); + return () => window.removeEventListener('keydown', handleEsc); + } + }, [isFullscreen, fullscreenType, setIsFullscreen]); const togglePictureInPicture = useCallback(async () => { if (!videoRef.current || !isPiPSupported) return; diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts index e8c7bc9..1d7a47a 100644 --- a/components/player/hooks/useDesktopPlayerLogic.ts +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -22,6 +22,7 @@ interface UseDesktopPlayerLogicProps { refs: DesktopPlayerState['refs']; data: DesktopPlayerState['data']; actions: DesktopPlayerState['actions']; + fullscreenType?: 'native' | 'window'; } export function useDesktopPlayerLogic({ @@ -32,7 +33,8 @@ export function useDesktopPlayerLogic({ onTimeUpdate, refs, data, - actions + actions, + fullscreenType = 'native' }: UseDesktopPlayerLogicProps) { const { videoRef, containerRef, progressBarRef, volumeBarRef, @@ -117,7 +119,8 @@ export function useDesktopPlayerLogic({ const fullscreenControls = useFullscreenControls({ containerRef, videoRef, isFullscreen, setIsFullscreen, - isPiPSupported, isAirPlaySupported, setIsPiPSupported, setIsAirPlaySupported + isPiPSupported, isAirPlaySupported, setIsPiPSupported, setIsAirPlaySupported, + fullscreenType }); const controlsVisibility = useControlsVisibility({ diff --git a/components/player/hooks/usePlayerSettings.ts b/components/player/hooks/usePlayerSettings.ts index 22208ac..58976db 100644 --- a/components/player/hooks/usePlayerSettings.ts +++ b/components/player/hooks/usePlayerSettings.ts @@ -20,6 +20,7 @@ export function usePlayerSettings() { adFilter: stored.adFilter, adFilterMode: stored.adFilterMode, adKeywords: stored.adKeywords, + fullscreenType: stored.fullscreenType, }; }); @@ -37,6 +38,7 @@ export function usePlayerSettings() { adFilter: stored.adFilter, adFilterMode: stored.adFilterMode, adKeywords: stored.adKeywords, + fullscreenType: stored.fullscreenType, }); }); return unsubscribe; @@ -89,6 +91,10 @@ export function usePlayerSettings() { updateSetting('adKeywords', value); }, [updateSetting]); + const setFullscreenType = useCallback((value: 'native' | 'window') => { + updateSetting('fullscreenType', value); + }, [updateSetting]); + return { ...settings, setAutoNextEpisode, @@ -100,5 +106,6 @@ export function usePlayerSettings() { setAdFilter, setAdFilterMode, setAdKeywords, + setFullscreenType, }; } diff --git a/components/player/web-fullscreen.css b/components/player/web-fullscreen.css new file mode 100644 index 0000000..338e450 --- /dev/null +++ b/components/player/web-fullscreen.css @@ -0,0 +1,18 @@ +/* Web Fullscreen (Window Fullscreen) Styles */ +.kvideo-container.is-web-fullscreen { + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100vw !important; + height: 100vh !important; + z-index: 9999 !important; + background: black !important; + border-radius: 0 !important; +} + +/* Ensure controls and video take up full space in web fullscreen */ +.kvideo-container.is-web-fullscreen video { + width: 100% !important; + height: 100% !important; + border-radius: 0 !important; +} diff --git a/components/ui/icons/utility-icons.tsx b/components/ui/icons/utility-icons.tsx index d0d554d..98321a8 100644 --- a/components/ui/icons/utility-icons.tsx +++ b/components/ui/icons/utility-icons.tsx @@ -172,4 +172,10 @@ export const UtilityIcons = { ), + Settings: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), }; diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index d2af77c..8272613 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -43,6 +43,7 @@ export interface AppSettings { realtimeLatency: boolean; // Enable real-time latency ping updates searchDisplayMode: SearchDisplayMode; // 'normal' = individual cards, 'grouped' = group same-name videos episodeReverseOrder: boolean; // Persist episode list reverse state + fullscreenType: 'native' | 'window'; // Fullscreen mode preference } import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers'; @@ -114,6 +115,7 @@ function getDefaultAppSettings(): AppSettings { realtimeLatency: false, searchDisplayMode: 'normal', episodeReverseOrder: false, + fullscreenType: 'native', }; } @@ -189,6 +191,7 @@ export const settingsStore = { realtimeLatency: parsed.realtimeLatency !== undefined ? parsed.realtimeLatency : false, searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal', episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false, + fullscreenType: parsed.fullscreenType === 'window' ? 'window' : 'native', }; } catch { // Even if localStorage fails, we should return defaults + ENV subscriptions diff --git a/package-lock.json b/package-lock.json index 468d6dc..c14e680 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.0.2", + "version": "4.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.0.2", + "version": "4.0.3", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index f4c0496..03daebe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.0.2", + "version": "4.0.3", "private": true, "scripts": { "dev": "next dev",