feat: Implement window fullscreen option for the player, enhance password gate protection logic, and add a settings icon.

This commit is contained in:
kuekhaoyang
2026-01-25 10:59:47 +08:00
parent c492dc310b
commit c9fde882c9
11 changed files with 131 additions and 20 deletions
+10 -4
View File
@@ -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);
}
};
+7 -2
View File
@@ -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 (
<div
ref={containerRef}
className="relative aspect-video bg-black rounded-[var(--radius-2xl)] group"
className={`relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.isFullscreen && fullscreenType === 'window' ? 'is-web-fullscreen' : ''
}`}
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
@@ -43,6 +43,8 @@ export function DesktopMoreMenu({
setAdFilter,
adFilterMode,
setAdFilterMode,
fullscreenType,
setFullscreenType,
} = usePlayerSettings();
const buttonRef = React.useRef<HTMLButtonElement>(null);
@@ -269,6 +271,44 @@ export function DesktopMoreMenu({
)}
</div>
{/* Fullscreen Mode Selector */}
<div className="px-3 py-2 sm:px-4 sm:py-2.5 flex items-center justify-between gap-4 sm:gap-6">
<div className="flex items-center gap-2 sm:gap-3 text-xs sm:text-sm text-[var(--text-color)]">
<Icons.Settings size={16} className="sm:w-[18px] sm:h-[18px]" />
<span></span>
</div>
<div className="relative">
<button
onClick={() => {
setFullscreenType(fullscreenType === 'native' ? 'window' : 'native');
}}
className="flex items-center gap-1 sm:gap-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-[10px] sm:text-xs rounded-[var(--radius-2xl)] px-2 sm:px-2.5 py-1 sm:py-1.5 outline-none hover:border-[var(--accent-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] transition-all cursor-pointer whitespace-nowrap"
>
<span>{fullscreenType === 'native' ? '系统全屏' : '网页全屏'}</span>
<Icons.Maximize size={12} className="text-[var(--text-color-secondary)]" />
</button>
</div>
</div>
{/* Fullscreen Mode Selector */}
<div className="px-3 py-2 sm:px-4 sm:py-2.5 flex items-center justify-between gap-4 sm:gap-6">
<div className="flex items-center gap-2 sm:gap-3 text-xs sm:text-sm text-[var(--text-color)]">
<Icons.Settings size={16} className="sm:w-[18px] sm:h-[18px]" />
<span></span>
</div>
<div className="relative">
<button
onClick={() => {
setFullscreenType(fullscreenType === 'native' ? 'window' : 'native');
}}
className="flex items-center gap-1 sm:gap-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-[10px] sm:text-xs rounded-[var(--radius-2xl)] px-2 sm:px-2.5 py-1 sm:py-1.5 outline-none hover:border-[var(--accent-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] transition-all cursor-pointer whitespace-nowrap"
>
<span>{fullscreenType === 'native' ? '系统全屏' : '网页全屏'}</span>
<Icons.Maximize size={12} className="text-[var(--text-color-secondary)]" />
</button>
</div>
</div>
{/* Skip Outro Switch */}
<div className="px-3 py-2 sm:px-4 sm:py-2.5">
<div className="flex items-center justify-between gap-4 sm:gap-6">
@@ -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;
@@ -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({
@@ -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,
};
}
+18
View File
@@ -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;
}
+6
View File
@@ -172,4 +172,10 @@ export const UtilityIcons = {
<circle cx="5" cy="12" r="1" />
</svg>
),
Settings: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
};
+3
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.0.2",
"version": "4.0.3",
"private": true,
"scripts": {
"dev": "next dev",