mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement window fullscreen option for the player, enhance password gate protection logic, and add a settings icon.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.0.2",
|
||||
"version": "4.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user