From 35f7f4d83a04ba94dcfd16955a7b58b0495e7e38 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 26 Mar 2026 13:51:45 +0800 Subject: [PATCH] feat: upgrade player fullscreen buffering and panel controls --- app/player/page.tsx | 64 +++- app/styles/video-player.css | 12 +- components/favorites/FavoritesSidebar.tsx | 19 +- components/history/WatchHistorySidebar.tsx | 19 +- components/player/DesktopVideoPlayer.tsx | 93 ++++-- components/player/EpisodeList.tsx | 244 +++++++++------ components/player/desktop/DesktopControls.tsx | 7 + .../player/desktop/DesktopControlsWrapper.tsx | 12 +- components/player/desktop/DesktopMoreMenu.tsx | 23 ++ components/player/desktop/DesktopOverlay.tsx | 26 ++ .../player/desktop/DesktopOverlayWrapper.tsx | 14 +- .../player/desktop/DesktopProgressBar.tsx | 6 + .../player/desktop/DesktopRightControls.tsx | 33 +- .../hooks/desktop/useDesktopShortcuts.ts | 7 + .../hooks/desktop/useFullscreenControls.ts | 287 ++++++++++++------ .../hooks/desktop/usePlaybackControls.ts | 36 ++- .../player/hooks/useDesktopPlayerLogic.ts | 14 +- .../player/hooks/useDesktopPlayerState.ts | 10 +- components/player/hooks/useHlsPlayer.ts | 8 +- components/player/web-fullscreen.css | 28 +- lib/hooks/useFloatingButtonPosition.ts | 267 ++++++++++++++++ 21 files changed, 994 insertions(+), 235 deletions(-) create mode 100644 lib/hooks/useFloatingButtonPosition.ts diff --git a/app/player/page.tsx b/app/player/page.tsx index 44eadbf..7772c66 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -2,7 +2,6 @@ import { Suspense, useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/Button'; import { VideoPlayer } from '@/components/player/VideoPlayer'; import { VideoMetadata } from '@/components/player/VideoMetadata'; import { EpisodeList } from '@/components/player/EpisodeList'; @@ -22,6 +21,16 @@ import { SegmentedControl } from '@/components/ui/SegmentedControl'; import { getSourceName } from '@/lib/utils/source-names'; import { retrieveGroupedSources, storeGroupedSources } from '@/lib/utils/grouped-sources-cache'; +type PlayerViewportMode = 'standard' | 'wide' | 'cinema'; + +const PLAYER_VIEWPORT_MODE_KEY = 'kvideo-player-viewport-mode'; +const PLAYER_VIEWPORT_MODE_ORDER: PlayerViewportMode[] = ['standard', 'wide', 'cinema']; +const PLAYER_VIEWPORT_MODE_LABELS: Record = { + standard: '标准', + wide: '宽屏', + cinema: '影院', +}; + function PlayerContent() { const searchParams = useSearchParams(); const router = useRouter(); @@ -44,12 +53,23 @@ function PlayerContent() { // Mobile tab state const [activeTab, setActiveTab] = useState<'episodes' | 'info'>('episodes'); + const [playerViewportMode, setPlayerViewportMode] = useState(() => { + if (typeof window === 'undefined') return 'standard'; + const saved = localStorage.getItem(PLAYER_VIEWPORT_MODE_KEY); + return saved === 'wide' || saved === 'cinema' || saved === 'standard' ? saved : 'standard'; + }); + const [isSourceSectionCollapsed, setIsSourceSectionCollapsed] = useState(false); + const [isEpisodeSectionCollapsed, setIsEpisodeSectionCollapsed] = useState(false); // Sync with store changes if any (though usually it's one-way from UI to store) useEffect(() => { setIsReversed(modeStore.getSettings().episodeReverseOrder); }, []); + useEffect(() => { + localStorage.setItem(PLAYER_VIEWPORT_MODE_KEY, playerViewportMode); + }, [playerViewportMode]); + // Migrate legacy long groupedSources URL to short gs key useEffect(() => { if (groupedSourcesParam && !gsKey) { @@ -341,6 +361,19 @@ function PlayerContent() { } }, [videoData, currentEpisode, isReversed, router, searchParams]); // handleEpisodeClick is not memoized, but uses stable hooks setters. wait, handleEpisodeClick is inline too! + const effectivePlayerViewportMode = useMemo(() => { + const manualIndex = PLAYER_VIEWPORT_MODE_ORDER.indexOf(playerViewportMode); + const collapsedCount = Number(isSourceSectionCollapsed) + Number(isEpisodeSectionCollapsed); + const autoIndex = Math.min(collapsedCount, PLAYER_VIEWPORT_MODE_ORDER.length - 1); + return PLAYER_VIEWPORT_MODE_ORDER[Math.max(manualIndex, autoIndex)]; + }, [playerViewportMode, isSourceSectionCollapsed, isEpisodeSectionCollapsed]); + + const playerGridClass = effectivePlayerViewportMode === 'cinema' + ? 'xl:grid-cols-[minmax(0,1.9fr)_minmax(280px,0.55fr)]' + : effectivePlayerViewportMode === 'wide' + ? 'xl:grid-cols-[minmax(0,1.65fr)_minmax(300px,0.72fr)]' + : 'xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.9fr)]'; + return (
{/* Glass Navbar */} @@ -359,9 +392,30 @@ function PlayerContent() { onRetry={fetchVideoDetails} /> ) : ( -
+
{/* Video Player Section */} -
+
+
+
+
+ 播放窗口大小 +
+
+ 右侧源列表或选集折叠后,会自动提升到更宽的布局 + {effectivePlayerViewportMode !== playerViewportMode && `,当前已自动切到${PLAYER_VIEWPORT_MODE_LABELS[effectivePlayerViewportMode]}`} +
+
+ + options={[ + { label: '标准', value: 'standard' }, + { label: '宽屏', value: 'wide' }, + { label: '影院', value: 'cinema' }, + ]} + value={playerViewportMode} + onChange={setPlayerViewportMode} + className="min-w-[240px]" + /> +
{ const params = new URLSearchParams(); params.set('id', String(newSource.id)); diff --git a/app/styles/video-player.css b/app/styles/video-player.css index a8dd9f0..16e5db2 100644 --- a/app/styles/video-player.css +++ b/app/styles/video-player.css @@ -80,6 +80,16 @@ pointer-events: none; } +.slider-buffer { + position: absolute; + left: 0; + top: 0; + height: 100%; + background: rgba(255, 255, 255, 0.4); + border-radius: var(--radius-full); + pointer-events: none; +} + .slider-thumb { position: absolute; top: 50%; @@ -424,4 +434,4 @@ z-index: 50; cursor: pointer; touch-action: manipulation; -} \ No newline at end of file +} diff --git a/components/favorites/FavoritesSidebar.tsx b/components/favorites/FavoritesSidebar.tsx index 9a5b711..9cd7007 100644 --- a/components/favorites/FavoritesSidebar.tsx +++ b/components/favorites/FavoritesSidebar.tsx @@ -14,6 +14,7 @@ import { FavoritesHeader } from './FavoritesHeader'; import { FavoritesList } from './FavoritesList'; import { FavoritesFooter } from './FavoritesFooter'; import { trapFocus } from '@/lib/accessibility/focus-management'; +import { useFloatingButtonPosition } from '@/lib/hooks/useFloatingButtonPosition'; export function FavoritesSidebar({ isPremium = false }: { isPremium?: boolean }) { const [isOpen, setIsOpen] = useState(false); @@ -26,6 +27,14 @@ export function FavoritesSidebar({ isPremium = false }: { isPremium?: boolean }) const { favorites, removeFavorite, clearFavorites } = useFavorites(isPremium); const sidebarRef = useRef(null); const cleanupFocusTrapRef = useRef<(() => void) | null>(null); + const { + floatingStyle, + onPointerDown, + consumeSyntheticClick, + } = useFloatingButtonPosition({ + storageKey: isPremium ? 'kvideo-premium-favorites-button-position' : 'kvideo-favorites-button-position', + defaultAnchor: 'left', + }); // Setup focus trap when sidebar opens useEffect(() => { @@ -84,9 +93,15 @@ export function FavoritesSidebar({ isPremium = false }: { isPremium?: boolean }) <> {/* Toggle Button - Left side */} diff --git a/components/history/WatchHistorySidebar.tsx b/components/history/WatchHistorySidebar.tsx index 4d6bf6e..ad072b7 100644 --- a/components/history/WatchHistorySidebar.tsx +++ b/components/history/WatchHistorySidebar.tsx @@ -13,6 +13,7 @@ import { HistoryHeader } from './HistoryHeader'; import { HistoryList } from './HistoryList'; import { HistoryFooter } from './HistoryFooter'; import { trapFocus } from '@/lib/accessibility/focus-management'; +import { useFloatingButtonPosition } from '@/lib/hooks/useFloatingButtonPosition'; export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean }) { const [isOpen, setIsOpen] = useState(false); @@ -24,6 +25,14 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean const { viewingHistory, removeFromHistory, clearHistory } = useHistory(isPremium); const sidebarRef = useRef(null); const cleanupFocusTrapRef = useRef<(() => void) | null>(null); + const { + floatingStyle, + onPointerDown, + consumeSyntheticClick, + } = useFloatingButtonPosition({ + storageKey: isPremium ? 'kvideo-premium-history-button-position' : 'kvideo-history-button-position', + defaultAnchor: 'right', + }); // Setup focus trap when sidebar opens useEffect(() => { @@ -82,9 +91,15 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean <> {/* Toggle Button */} diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index 635d647..73cf84c 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -16,6 +16,11 @@ import { useIsIOS, useIsMobile } from '@/lib/hooks/mobile/useDeviceDetection'; import { useDoubleTap } from '@/lib/hooks/mobile/useDoubleTap'; import './web-fullscreen.css'; +type WebFullscreenSize = 'full' | 'large' | 'focused'; + +const WEB_FULLSCREEN_SIZE_KEY = 'kvideo-web-fullscreen-size'; +const WEB_FULLSCREEN_SIZE_ORDER: WebFullscreenSize[] = ['full', 'large', 'focused']; + interface DesktopVideoPlayerProps { src: string; poster?: string; @@ -54,6 +59,12 @@ export function DesktopVideoPlayer({ const { fullscreenType: settingsFullscreenType } = usePlayerSettings(); const isIOS = useIsIOS(); const isMobile = useIsMobile(); + const [webFullscreenSize, setWebFullscreenSize] = React.useState(() => { + if (typeof window === 'undefined') return 'full'; + const saved = localStorage.getItem(WEB_FULLSCREEN_SIZE_KEY); + return saved === 'large' || saved === 'focused' || saved === 'full' ? saved : 'full'; + }); + const [fullscreenClock, setFullscreenClock] = React.useState(''); // Detect actual video resolution const videoResolution = useVideoResolution(refs.videoRef); @@ -66,7 +77,7 @@ export function DesktopVideoPlayer({ }, [videoResolution, onResolutionDetected]); // Danmaku - const { danmakuEnabled, setDanmakuEnabled, comments: danmakuComments } = useDanmaku({ + const { danmakuEnabled, comments: danmakuComments } = useDanmaku({ videoTitle, episodeName, episodeIndex: currentEpisodeIndex, @@ -101,7 +112,32 @@ export function DesktopVideoPlayer({ : settingsFullscreenType; // Check if we need to force landscape (iOS + Fullscreen + Portrait) - const shouldForceLandscape = data.isFullscreen && fullscreenType === 'window' && isIOS && !isLandscape; + const shouldForceLandscape = data.fullscreenMode === 'window' && isIOS && !isLandscape; + + React.useEffect(() => { + localStorage.setItem(WEB_FULLSCREEN_SIZE_KEY, webFullscreenSize); + }, [webFullscreenSize]); + + React.useEffect(() => { + if (!data.isFullscreen) { + setFullscreenClock(''); + return; + } + + const formatter = new Intl.DateTimeFormat('zh-CN', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + + const updateClock = () => { + setFullscreenClock(formatter.format(new Date())); + }; + + updateClock(); + const interval = window.setInterval(updateClock, 30000); + return () => window.clearInterval(interval); + }, [data.isFullscreen]); // Initialize HLS Player useHlsPlayer({ @@ -123,15 +159,15 @@ export function DesktopVideoPlayer({ const { setShowControls, + setBufferedTime, setIsLoading, - setCurrentTime, - setDuration, } = actions; // Reset loading state and show spinner when source changes React.useEffect(() => { setIsLoading(true); - }, [src, setIsLoading]); + setBufferedTime(0); + }, [src, setBufferedTime, setIsLoading]); const logic = useDesktopPlayerLogic({ src, @@ -147,7 +183,7 @@ export function DesktopVideoPlayer({ }); // Auto-skip intro/outro and auto-next episode - const { isOutroActive, isTransitioningToNextEpisode } = useAutoSkip({ + const { isTransitioningToNextEpisode } = useAutoSkip({ videoRef, currentTime, duration, @@ -176,9 +212,21 @@ export function DesktopVideoPlayer({ handlePause, handleTimeUpdateEvent, handleLoadedMetadata, + handleProgressEvent, handleVideoError, } = logic; + const cycleWebFullscreenSize = React.useCallback(() => { + setWebFullscreenSize((current) => { + const currentIndex = WEB_FULLSCREEN_SIZE_ORDER.indexOf(current); + return WEB_FULLSCREEN_SIZE_ORDER[(currentIndex + 1) % WEB_FULLSCREEN_SIZE_ORDER.length]; + }); + }, []); + + const stageClassName = data.fullscreenMode === 'window' + ? `kvideo-stage kvideo-web-fullscreen-stage web-fullscreen-size-${webFullscreenSize}` + : 'kvideo-stage absolute inset-0'; + // Mobile double-tap gesture for skip forward/backward const { handleTap } = useDoubleTap({ onSingleTap: handleTouchToggleControls, @@ -204,15 +252,16 @@ export function DesktopVideoPlayer({ return (
{ handleMouseMove(); }} onMouseLeave={() => isPlaying && setShowControls(false)} > - {/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */} -
-
+
+ {/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */} +
+
{/* Video Element */}
diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx index 8dab0ce..a34a74d 100644 --- a/components/player/EpisodeList.tsx +++ b/components/player/EpisodeList.tsx @@ -42,6 +42,10 @@ interface EpisodeListProps { currentResolution?: VideoResolutionInfo | null; // Probed resolutions for all sources (key: "source:id") sourceResolutions?: Record; + sourceSectionCollapsed?: boolean; + onSourceSectionCollapseChange?: (collapsed: boolean) => void; + episodeSectionCollapsed?: boolean; + onEpisodeSectionCollapseChange?: (collapsed: boolean) => void; } export function EpisodeList({ @@ -55,6 +59,10 @@ export function EpisodeList({ onSourceChange, currentResolution, sourceResolutions, + sourceSectionCollapsed = false, + onSourceSectionCollapseChange, + episodeSectionCollapsed = false, + onEpisodeSectionCollapseChange, }: EpisodeListProps) { const listRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); @@ -89,6 +97,12 @@ export function EpisodeList({ return sources.find(s => s.source === currentSource) || null; }, [sources, currentSource]); + useEffect(() => { + if (sourceSectionCollapsed) { + setSourceExpanded(false); + } + }, [sourceSectionCollapsed]); + // Sort sources by latency const sortedSources = useMemo(() => { if (!sources) return []; @@ -214,7 +228,7 @@ export function EpisodeList({ // Keyboard navigation useKeyboardNavigation({ - enabled: true, + enabled: !episodeSectionCollapsed, containerRef: listRef, currentIndex: getDisplayIndex(currentEpisode), itemCount: episodes?.length || 0, @@ -237,51 +251,88 @@ export function EpisodeList({ }); const showReverseToggle = episodes && episodes.length > 1; + const currentEpisodeLabel = episodes?.[currentEpisode]?.name || `第${currentEpisode + 1}集`; return ( - {/* Integrated Source Selector Header */} {showSourceSelector && (
- + +
- {/* Expanded source list */} - {sourceExpanded && ( -
-
+
+
+ + + {!sourceSectionCollapsed && ( -
+ )} +
+ +
+ + 当前线路:{currentSourceInfo?.sourceName || currentSourceInfo?.source || '未知来源'} + + 共 {sources!.length} 条 +
+
+ + {/* Expanded source list */} + {!sourceSectionCollapsed && sourceExpanded && ( +
{(() => { const MAX_VISIBLE = 5; const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE); @@ -487,15 +538,14 @@ export function EpisodeList({
)} - {/* Episode List Header */} -

+
选集 {episodes && ( {episodes.length} )} {/* Reverse order toggle button - only show when more than 1 episode */} - {showReverseToggle && ( + {showReverseToggle && !episodeSectionCollapsed && ( )} -

- -
- {displayEpisodes && displayEpisodes.length > 0 ? ( - displayEpisodes.map((episode, displayIndex) => { - const originalIndex = getOriginalIndex(displayIndex); - const isCurrentEpisode = currentEpisode === originalIndex; - - return ( - - ); - }) - ) : ( -
- -

暂无剧集信息

-
- )} +
+ + {episodeSectionCollapsed ? ( +
+
+ 当前选集 + + {currentEpisodeLabel} + +
+
+ ) : ( +
+ {displayEpisodes && displayEpisodes.length > 0 ? ( + displayEpisodes.map((episode, displayIndex) => { + const originalIndex = getOriginalIndex(displayIndex); + const isCurrentEpisode = currentEpisode === originalIndex; + + return ( + + ); + }) + ) : ( +
+ +

暂无剧集信息

+
+ )} +
+ )} ); } diff --git a/components/player/desktop/DesktopControls.tsx b/components/player/desktop/DesktopControls.tsx index ace3e6f..3ffb46a 100644 --- a/components/player/desktop/DesktopControls.tsx +++ b/components/player/desktop/DesktopControls.tsx @@ -8,9 +8,12 @@ interface DesktopControlsProps { isPlaying: boolean; currentTime: number; duration: number; + bufferedTime: number; volume: number; isMuted: boolean; isFullscreen: boolean; + isNativeFullscreen: boolean; + isWebFullscreen: boolean; showVolumeBar: boolean; @@ -25,6 +28,8 @@ interface DesktopControlsProps { onVolumeChange: (e: React.MouseEvent) => void; onVolumeMouseDown: (e: React.MouseEvent) => void; onToggleFullscreen: () => void; + onToggleNativeFullscreen: () => void; + onToggleWebFullscreen: () => void; onTogglePictureInPicture: () => void; onShowAirPlayMenu: () => void; onShowCastMenu: () => void; @@ -39,6 +44,7 @@ export function DesktopControls(props: DesktopControlsProps) { showControls, currentTime, duration, + bufferedTime, progressBarRef, onProgressClick, onProgressMouseDown, @@ -60,6 +66,7 @@ export function DesktopControls(props: DesktopControlsProps) { progressBarRef={progressBarRef} currentTime={currentTime} duration={duration} + bufferedTime={bufferedTime} onProgressClick={onProgressClick} onProgressMouseDown={onProgressMouseDown} onProgressTouchStart={onProgressTouchStart} diff --git a/components/player/desktop/DesktopControlsWrapper.tsx b/components/player/desktop/DesktopControlsWrapper.tsx index 0a9deb9..83880b2 100644 --- a/components/player/desktop/DesktopControlsWrapper.tsx +++ b/components/player/desktop/DesktopControlsWrapper.tsx @@ -6,19 +6,20 @@ import { useDesktopPlayerLogic } from '../hooks/useDesktopPlayerLogic'; interface DesktopControlsWrapperProps { src: string; data: ReturnType['data']; - actions: ReturnType['actions']; logic: ReturnType; refs: ReturnType['refs']; } -export function DesktopControlsWrapper({ src, data, actions, logic, refs }: DesktopControlsWrapperProps) { +export function DesktopControlsWrapper({ src, data, logic, refs }: DesktopControlsWrapperProps) { const { isPlaying, currentTime, duration, + bufferedTime, volume, isMuted, isFullscreen, + fullscreenMode, showControls, showVolumeBar, isPiPSupported, @@ -32,6 +33,8 @@ export function DesktopControlsWrapper({ src, data, actions, logic, refs }: Desk handleVolumeChange, handleVolumeMouseDown, toggleFullscreen, + toggleNativeFullscreen, + toggleWindowFullscreen, togglePictureInPicture, showAirPlayMenu, showCastMenu, @@ -54,9 +57,12 @@ export function DesktopControlsWrapper({ src, data, actions, logic, refs }: Desk isPlaying={isPlaying} currentTime={currentTime} duration={duration} + bufferedTime={bufferedTime} volume={volume} isMuted={isMuted} isFullscreen={isFullscreen} + isNativeFullscreen={fullscreenMode === 'native'} + isWebFullscreen={fullscreenMode === 'window'} showVolumeBar={showVolumeBar} isPiPSupported={isPiPSupported} isAirPlaySupported={isAirPlaySupported} @@ -69,6 +75,8 @@ export function DesktopControlsWrapper({ src, data, actions, logic, refs }: Desk onVolumeChange={handleVolumeChange} onVolumeMouseDown={handleVolumeMouseDown} onToggleFullscreen={toggleFullscreen} + onToggleNativeFullscreen={toggleNativeFullscreen} + onToggleWebFullscreen={toggleWindowFullscreen} onTogglePictureInPicture={togglePictureInPicture} onShowAirPlayMenu={showAirPlayMenu} onShowCastMenu={showCastMenu} diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx index 445c63c..71d8699 100644 --- a/components/player/desktop/DesktopMoreMenu.tsx +++ b/components/player/desktop/DesktopMoreMenu.tsx @@ -14,6 +14,8 @@ interface DesktopMoreMenuProps { onMouseEnter: () => void; onMouseLeave: () => void; onCopyLink: (type?: 'original' | 'proxy') => void; + webFullscreenSize: 'full' | 'large' | 'focused'; + onCycleWebFullscreenSize: () => void; containerRef: React.RefObject; isRotated?: boolean; } @@ -25,6 +27,8 @@ export function DesktopMoreMenu({ onMouseEnter, onMouseLeave, onCopyLink, + webFullscreenSize, + onCycleWebFullscreenSize, containerRef, isRotated = false }: DesktopMoreMenuProps) { @@ -69,6 +73,11 @@ export function DesktopMoreMenu({ heuristic: '智能(Beta)', aggressive: '激进' }; + const WEB_FULLSCREEN_SIZE_LABELS: Record<'full' | 'large' | 'focused', string> = { + full: '铺满窗口', + large: '大窗模式', + focused: '聚焦影院', + }; const [isFullscreen, setIsFullscreen] = React.useState(false); @@ -346,6 +355,20 @@ export function DesktopMoreMenu({
+
+
+ + 网页全屏尺寸 +
+ +
+ {/* Show Mode Indicator Switch */}
diff --git a/components/player/desktop/DesktopOverlay.tsx b/components/player/desktop/DesktopOverlay.tsx index 7353b5c..f16621c 100644 --- a/components/player/desktop/DesktopOverlay.tsx +++ b/components/player/desktop/DesktopOverlay.tsx @@ -17,6 +17,8 @@ interface DesktopOverlayProps { showToast: boolean; toastMessage: string | null; showControls: boolean; + isFullscreen: boolean; + fullscreenClock: string; onTogglePlay: () => void; onSkipForward: () => void; onSkipBackward: () => void; @@ -34,6 +36,8 @@ interface DesktopOverlayProps { onSpeedChange: (speed: number) => void; onSpeedMenuMouseEnter: () => void; onSpeedMenuMouseLeave: () => void; + webFullscreenSize: 'full' | 'large' | 'focused'; + onCycleWebFullscreenSize: () => void; containerRef: React.RefObject; isRotated?: boolean; } @@ -50,6 +54,8 @@ export function DesktopOverlay({ isSkipBackwardAnimatingOut, showToast, toastMessage, + isFullscreen, + fullscreenClock, onTogglePlay, onSkipForward, onSkipBackward, @@ -67,6 +73,8 @@ export function DesktopOverlay({ onSpeedChange, onSpeedMenuMouseEnter, onSpeedMenuMouseLeave, + webFullscreenSize, + onCycleWebFullscreenSize, containerRef, isRotated = false, }: DesktopOverlayProps) { @@ -84,11 +92,29 @@ export function DesktopOverlay({ onMouseEnter={onMoreMenuMouseEnter} onMouseLeave={onMoreMenuMouseLeave} onCopyLink={onCopyLink} + webFullscreenSize={webFullscreenSize} + onCycleWebFullscreenSize={onCycleWebFullscreenSize} containerRef={containerRef} isRotated={isRotated} />
+ {isFullscreen && fullscreenClock && ( +
+
+
+ + + {fullscreenClock} + +
+
+
+ )} + {/* Speed Menu (Top Right) - Moved slightly down and lower z-index */}
['data']; - actions: ReturnType['actions']; showControls: boolean; + isFullscreen: boolean; + fullscreenClock: string; isRotated?: boolean; onTogglePlay: () => void; onSkipForward: () => void; @@ -25,13 +26,16 @@ interface DesktopOverlayWrapperProps { onSpeedChange: (speed: number) => void; onSpeedMenuMouseEnter: () => void; onSpeedMenuMouseLeave: () => void; + webFullscreenSize: 'full' | 'large' | 'focused'; + onCycleWebFullscreenSize: () => void; containerRef: React.RefObject; } export function DesktopOverlayWrapper({ data, - actions, showControls, + isFullscreen, + fullscreenClock, isRotated = false, onTogglePlay, onSkipForward, @@ -50,6 +54,8 @@ export function DesktopOverlayWrapper({ onSpeedChange, onSpeedMenuMouseEnter, onSpeedMenuMouseLeave, + webFullscreenSize, + onCycleWebFullscreenSize, containerRef, }: DesktopOverlayWrapperProps) { const { @@ -79,6 +85,8 @@ export function DesktopOverlayWrapper({ showToast={showToast} toastMessage={toastMessage} showControls={showControls} + isFullscreen={isFullscreen} + fullscreenClock={fullscreenClock} onTogglePlay={onTogglePlay} onSkipForward={onSkipForward} onSkipBackward={onSkipBackward} @@ -95,6 +103,8 @@ export function DesktopOverlayWrapper({ onSpeedChange={onSpeedChange} onSpeedMenuMouseEnter={onSpeedMenuMouseEnter} onSpeedMenuMouseLeave={onSpeedMenuMouseLeave} + webFullscreenSize={webFullscreenSize} + onCycleWebFullscreenSize={onCycleWebFullscreenSize} containerRef={containerRef} isRotated={isRotated} /> diff --git a/components/player/desktop/DesktopProgressBar.tsx b/components/player/desktop/DesktopProgressBar.tsx index 59b7a2d..80696bd 100644 --- a/components/player/desktop/DesktopProgressBar.tsx +++ b/components/player/desktop/DesktopProgressBar.tsx @@ -5,6 +5,7 @@ interface DesktopProgressBarProps { currentTime: number; duration: number; + bufferedTime: number; onProgressClick: (e: React.MouseEvent) => void; onProgressMouseDown: (e: React.MouseEvent) => void; onProgressTouchStart: (e: React.TouchEvent) => void; @@ -14,6 +15,7 @@ export function DesktopProgressBar({ progressBarRef, currentTime, duration, + bufferedTime, onProgressClick, onProgressMouseDown, onProgressTouchStart @@ -28,6 +30,10 @@ export function DesktopProgressBar({ onTouchStart={onProgressTouchStart} style={{ pointerEvents: 'auto' }} > +
void; + onToggleNativeFullscreen: () => void; + onToggleWebFullscreen: () => void; onTogglePictureInPicture: () => void; onShowAirPlayMenu: () => void; onShowCastMenu: () => void; } export function DesktopRightControls({ - isFullscreen, + isNativeFullscreen, + isWebFullscreen, isPiPSupported, isAirPlaySupported, isCastAvailable, - isProxied, - onToggleFullscreen, + onToggleNativeFullscreen, + onToggleWebFullscreen, onTogglePictureInPicture, onShowAirPlayMenu, onShowCastMenu @@ -70,13 +72,24 @@ export function DesktopRightControls({ ) } - {/* Fullscreen */} + {/* Web Fullscreen */} + + {/* Native Fullscreen */} +
); diff --git a/components/player/hooks/desktop/useDesktopShortcuts.ts b/components/player/hooks/desktop/useDesktopShortcuts.ts index 881416a..94fe194 100644 --- a/components/player/hooks/desktop/useDesktopShortcuts.ts +++ b/components/player/hooks/desktop/useDesktopShortcuts.ts @@ -8,6 +8,7 @@ interface UseDesktopShortcutsProps { togglePlay: () => void; toggleMute: () => void; toggleFullscreen: () => void; + toggleWindowFullscreen: () => void; togglePictureInPicture: () => void; skipForward: () => void; skipBackward: () => void; @@ -26,6 +27,7 @@ export function useDesktopShortcuts({ togglePlay, toggleMute, toggleFullscreen, + toggleWindowFullscreen, togglePictureInPicture, skipForward, skipBackward, @@ -63,6 +65,10 @@ export function useDesktopShortcuts({ e.preventDefault(); toggleFullscreen(); break; + case 'w': + e.preventDefault(); + toggleWindowFullscreen(); + break; case 'm': e.preventDefault(); toggleMute(); @@ -122,6 +128,7 @@ export function useDesktopShortcuts({ togglePlay, toggleMute, toggleFullscreen, + toggleWindowFullscreen, togglePictureInPicture, skipForward, skipBackward, diff --git a/components/player/hooks/desktop/useFullscreenControls.ts b/components/player/hooks/desktop/useFullscreenControls.ts index c66f462..7749803 100644 --- a/components/player/hooks/desktop/useFullscreenControls.ts +++ b/components/player/hooks/desktop/useFullscreenControls.ts @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo } from 'react'; +import type { FullscreenMode } from '../useDesktopPlayerState'; interface UseFullscreenControlsProps { containerRef: React.RefObject; videoRef: React.RefObject; - isFullscreen: boolean; setIsFullscreen: (fullscreen: boolean) => void; + fullscreenMode: FullscreenMode; + setFullscreenMode: (mode: FullscreenMode) => void; isPiPSupported: boolean; isAirPlaySupported: boolean; setIsPiPSupported: (supported: boolean) => void; @@ -15,14 +17,42 @@ interface UseFullscreenControlsProps { export function useFullscreenControls({ containerRef, videoRef, - isFullscreen, setIsFullscreen, + fullscreenMode, + setFullscreenMode, isPiPSupported, isAirPlaySupported, setIsPiPSupported, setIsAirPlaySupported, fullscreenType = 'native' }: UseFullscreenControlsProps) { + const lockLandscape = useCallback(async () => { + if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.lock) { + try { + await (window.screen as any).orientation.lock('landscape'); + } catch (error) { + console.warn('Orientation lock failed:', error); + } + } + }, []); + + const unlockOrientation = useCallback(() => { + if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.unlock) { + try { + (window.screen as any).orientation.unlock(); + } catch { + // Ignore unlock errors from unsupported browsers. + } + } + }, []); + + const getNativeFullscreenElement = useCallback(() => ( + document.fullscreenElement || + (document as any).webkitFullscreenElement || + (document as any).mozFullScreenElement || + (document as any).msFullscreenElement + ), []); + useEffect(() => { if (typeof document !== 'undefined') { const hasNativePiP = 'pictureInPictureEnabled' in document; @@ -37,99 +67,146 @@ export function useFullscreenControls({ } }, [setIsPiPSupported, setIsAirPlaySupported, videoRef]); - const toggleFullscreen = useCallback(async () => { + const exitNativeFullscreen = useCallback(async () => { + try { + if (document.exitFullscreen) { + await document.exitFullscreen(); + } else if ((document as any).webkitExitFullscreen) { + await (document as any).webkitExitFullscreen(); + } else if ((document as any).mozCancelFullScreen) { + await (document as any).mozCancelFullScreen(); + } else if ((document as any).msExitFullscreen) { + await (document as any).msExitFullscreen(); + } + } catch (error) { + console.error('Failed to exit fullscreen:', error); + } finally { + unlockOrientation(); + setIsFullscreen(false); + setFullscreenMode('none'); + } + }, [setFullscreenMode, setIsFullscreen, unlockOrientation]); + + const exitWindowFullscreen = useCallback(() => { + unlockOrientation(); + setIsFullscreen(false); + setFullscreenMode('none'); + }, [setFullscreenMode, setIsFullscreen, unlockOrientation]); + + const enterWindowFullscreen = useCallback(async () => { + if (fullscreenMode === 'native') { + await exitNativeFullscreen(); + } + + setFullscreenMode('window'); + setIsFullscreen(true); + await lockLandscape(); + }, [exitNativeFullscreen, fullscreenMode, lockLandscape, setFullscreenMode, setIsFullscreen]); + + const enterNativeFullscreen = useCallback(async () => { if (!containerRef.current) return; - if (!isFullscreen) { - if (fullscreenType === 'window') { - setIsFullscreen(true); - return; + if (fullscreenMode === 'window') { + exitWindowFullscreen(); + } + + try { + if (containerRef.current.requestFullscreen) { + await containerRef.current.requestFullscreen(); + } else if ((containerRef.current as any).webkitRequestFullscreen) { + await (containerRef.current as any).webkitRequestFullscreen(); + } else if ((containerRef.current as any).mozRequestFullScreen) { + await (containerRef.current as any).mozRequestFullScreen(); + } else if ((containerRef.current as any).msRequestFullscreen) { + await (containerRef.current as any).msRequestFullscreen(); + } else if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { + (videoRef.current as any).webkitEnterFullscreen(); } - try { - if (containerRef.current.requestFullscreen) { - await containerRef.current.requestFullscreen(); - } else if ((containerRef.current as any).webkitRequestFullscreen) { - await (containerRef.current as any).webkitRequestFullscreen(); - } else if ((containerRef.current as any).mozRequestFullScreen) { - await (containerRef.current as any).mozRequestFullScreen(); - } else if ((containerRef.current as any).msRequestFullscreen) { - await (containerRef.current as any).msRequestFullscreen(); - } else if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { + setFullscreenMode('native'); + setIsFullscreen(true); + await lockLandscape(); + } catch (error) { + console.warn('Fullscreen request failed, trying fallback:', error); + if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { + try { (videoRef.current as any).webkitEnterFullscreen(); + setFullscreenMode('native'); + setIsFullscreen(true); + } catch (fallbackError) { + console.error('Final fullscreen fallback failed:', fallbackError); } - - if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.lock) { - try { - await (window.screen as any).orientation.lock('landscape'); - } catch (e) { - console.warn('Orientation lock failed:', e); - } - } - } catch (error) { - console.warn('Fullscreen request failed, trying fallback:', error); - if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { - try { - (videoRef.current as any).webkitEnterFullscreen(); - } catch (e) { - console.error('Final fullscreen fallback failed:', e); - } - } - } - } else { - if (fullscreenType === 'window') { - setIsFullscreen(false); - return; - } - - try { - if (document.exitFullscreen) { - await document.exitFullscreen(); - } else if ((document as any).webkitExitFullscreen) { - await (document as any).webkitExitFullscreen(); - } else if ((document as any).mozCancelFullScreen) { - await (document as any).mozCancelFullScreen(); - } else if ((document as any).msExitFullscreen) { - await (document as any).msExitFullscreen(); - } - - if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.unlock) { - try { - (window.screen as any).orientation.unlock(); - } catch (e) { - console.warn('Orientation unlock failed:', e); - } - } - } catch (error) { - console.error('Failed to exit fullscreen:', error); } } - }, [containerRef, videoRef, isFullscreen, fullscreenType, setIsFullscreen]); + }, [ + containerRef, + exitWindowFullscreen, + fullscreenMode, + lockLandscape, + setFullscreenMode, + setIsFullscreen, + videoRef, + ]); + + const toggleWindowFullscreen = useCallback(async () => { + if (fullscreenMode === 'window') { + exitWindowFullscreen(); + return; + } + + await enterWindowFullscreen(); + }, [enterWindowFullscreen, exitWindowFullscreen, fullscreenMode]); + + const toggleNativeFullscreen = useCallback(async () => { + if (fullscreenMode === 'native') { + await exitNativeFullscreen(); + return; + } + + await enterNativeFullscreen(); + }, [enterNativeFullscreen, exitNativeFullscreen, fullscreenMode]); + + const toggleFullscreen = useCallback(async () => { + if (fullscreenMode === 'window') { + exitWindowFullscreen(); + return; + } + + if (fullscreenMode === 'native') { + await exitNativeFullscreen(); + return; + } + + if (fullscreenType === 'window') { + await enterWindowFullscreen(); + return; + } + + await enterNativeFullscreen(); + }, [ + enterNativeFullscreen, + enterWindowFullscreen, + exitNativeFullscreen, + exitWindowFullscreen, + fullscreenMode, + fullscreenType, + ]); useEffect(() => { const handleFullscreenChange = () => { - const isInFullscreen = !!( - document.fullscreenElement || - (document as any).webkitFullscreenElement || - (document as any).mozFullScreenElement || - (document as any).msFullscreenElement - ); + const nativeFullscreenElement = getNativeFullscreenElement(); - // Only update if not in window mode, or if exiting native mode - if (fullscreenType === 'native' || !isInFullscreen) { - setIsFullscreen(isInFullscreen); + if (nativeFullscreenElement) { + setIsFullscreen(true); + setFullscreenMode('native'); + lockLandscape().catch(() => { }); + return; } - if (isInFullscreen) { - if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.lock) { - (window.screen as any).orientation.lock('landscape').catch(() => { }); - } - } else { - if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.unlock) { - try { - (window.screen as any).orientation.unlock(); - } catch (e) { } - } + if (fullscreenMode === 'native') { + unlockOrientation(); + setIsFullscreen(false); + setFullscreenMode('none'); } }; @@ -144,19 +221,35 @@ export function useFullscreenControls({ document.removeEventListener('mozfullscreenchange', handleFullscreenChange); document.removeEventListener('MSFullscreenChange', handleFullscreenChange); }; - }, [setIsFullscreen, fullscreenType]); + }, [fullscreenMode, getNativeFullscreenElement, lockLandscape, setFullscreenMode, setIsFullscreen, unlockOrientation]); 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]); + if (fullscreenMode !== 'window') return; + + const previousOverflow = document.body.style.overflow; + const previousOverscroll = document.body.style.overscrollBehavior; + + document.body.style.overflow = 'hidden'; + document.body.style.overscrollBehavior = 'contain'; + + return () => { + document.body.style.overflow = previousOverflow; + document.body.style.overscrollBehavior = previousOverscroll; + }; + }, [fullscreenMode]); + + useEffect(() => { + if (fullscreenMode !== 'window') return; + + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + exitWindowFullscreen(); + } + }; + + window.addEventListener('keydown', handleEsc); + return () => window.removeEventListener('keydown', handleEsc); + }, [exitWindowFullscreen, fullscreenMode]); const togglePictureInPicture = useCallback(async () => { if (!videoRef.current || !isPiPSupported) return; @@ -186,9 +279,17 @@ export function useFullscreenControls({ const fullscreenActions = useMemo(() => ({ toggleFullscreen, + toggleNativeFullscreen, + toggleWindowFullscreen, togglePictureInPicture, showAirPlayMenu - }), [toggleFullscreen, togglePictureInPicture, showAirPlayMenu]); + }), [ + toggleFullscreen, + toggleNativeFullscreen, + toggleWindowFullscreen, + togglePictureInPicture, + showAirPlayMenu + ]); return fullscreenActions; } diff --git a/components/player/hooks/desktop/usePlaybackControls.ts b/components/player/hooks/desktop/usePlaybackControls.ts index 8ede9e8..0bc7f43 100644 --- a/components/player/hooks/desktop/usePlaybackControls.ts +++ b/components/player/hooks/desktop/usePlaybackControls.ts @@ -10,6 +10,7 @@ interface UsePlaybackControlsProps { initialTime: number; shouldAutoPlay: boolean; setDuration: (duration: number) => void; + setBufferedTime: (time: number) => void; setCurrentTime: (time: number) => void; onTimeUpdate?: (currentTime: number, duration: number) => void; onError?: (error: string) => void; @@ -30,6 +31,7 @@ export function usePlaybackControls({ initialTime, shouldAutoPlay, setDuration, + setBufferedTime, setCurrentTime, onTimeUpdate, onError, @@ -41,6 +43,28 @@ export function usePlaybackControls({ volume, isMuted }: UsePlaybackControlsProps) { + const updateBufferedTime = useCallback(() => { + if (!videoRef.current) return; + + const video = videoRef.current; + const { buffered, currentTime } = video; + let bufferedEnd = 0; + + for (let index = 0; index < buffered.length; index += 1) { + const rangeStart = buffered.start(index); + const rangeEnd = buffered.end(index); + + if (currentTime >= rangeStart && currentTime <= rangeEnd + 0.25) { + bufferedEnd = rangeEnd; + break; + } + + bufferedEnd = Math.max(bufferedEnd, rangeEnd); + } + + setBufferedTime(bufferedEnd); + }, [setBufferedTime, videoRef]); + const togglePlay = useCallback(() => { if (!videoRef.current) return; if (isPlaying) { @@ -67,14 +91,16 @@ export function usePlaybackControls({ const total = videoRef.current.duration; setCurrentTime(current); setDuration(total); + updateBufferedTime(); if (onTimeUpdate) { onTimeUpdate(current, total); } - }, [videoRef, isDraggingProgressRef, setCurrentTime, setDuration, onTimeUpdate]); + }, [videoRef, isDraggingProgressRef, setCurrentTime, setDuration, updateBufferedTime, onTimeUpdate]); const handleLoadedMetadata = useCallback(() => { if (!videoRef.current) return; setDuration(videoRef.current.duration); + updateBufferedTime(); // Removed setIsLoading(false) because metadata loading is too early. // We wait for onCanPlay to set isLoading to false. @@ -98,7 +124,7 @@ export function usePlaybackControls({ videoRef.current.play().catch((err: Error) => { console.warn('Autoplay was prevented:', err); }); - }, [videoRef, setDuration, setIsLoading, initialTime, playbackRate, volume, isMuted]); + }, [videoRef, setDuration, updateBufferedTime, initialTime, playbackRate, volume, isMuted]); // Handle late initialization of initialTime (e.g. from async storage hydration) useEffect(() => { @@ -131,6 +157,10 @@ export function usePlaybackControls({ } }, [setIsLoading, onError]); + const handleProgressEvent = useCallback(() => { + updateBufferedTime(); + }, [updateBufferedTime]); + const changePlaybackSpeed = useCallback((speed: number) => { if (!videoRef.current) return; videoRef.current.playbackRate = speed; @@ -159,6 +189,7 @@ export function usePlaybackControls({ handlePause, handleTimeUpdateEvent, handleLoadedMetadata, + handleProgressEvent, handleVideoError, changePlaybackSpeed, formatTime @@ -168,6 +199,7 @@ export function usePlaybackControls({ handlePause, handleTimeUpdateEvent, handleLoadedMetadata, + handleProgressEvent, handleVideoError, changePlaybackSpeed ]); diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts index ccbfae4..7995d21 100644 --- a/components/player/hooks/useDesktopPlayerLogic.ts +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -47,13 +47,11 @@ export function useDesktopPlayerLogic({ const { isPlaying, - currentTime, duration, volume, isMuted, - isFullscreen, + fullscreenMode, showControls, - isLoading, playbackRate, showSpeedMenu, isPiPSupported, @@ -69,9 +67,11 @@ export function useDesktopPlayerLogic({ setIsPlaying, setCurrentTime, setDuration, + setBufferedTime, setVolume, setIsMuted, setIsFullscreen, + setFullscreenMode, setShowControls, setIsLoading, setPlaybackRate, @@ -94,7 +94,7 @@ export function useDesktopPlayerLogic({ const playbackControls = usePlaybackControls({ videoRef, isPlaying, setIsPlaying, setIsLoading, - initialTime, shouldAutoPlay, setDuration, setCurrentTime, onTimeUpdate, onError, + initialTime, shouldAutoPlay, setDuration, setBufferedTime, setCurrentTime, onTimeUpdate, onError, isDraggingProgressRef, speedMenuTimeoutRef, playbackRate, setPlaybackRate, setShowSpeedMenu, volume, isMuted }); @@ -122,7 +122,7 @@ export function useDesktopPlayerLogic({ }); const fullscreenControls = useFullscreenControls({ - containerRef, videoRef, isFullscreen, setIsFullscreen, + containerRef, videoRef, setIsFullscreen, fullscreenMode, setFullscreenMode, isPiPSupported, isAirPlaySupported, setIsPiPSupported, setIsAirPlaySupported, fullscreenType }); @@ -146,6 +146,7 @@ export function useDesktopPlayerLogic({ togglePlay: playbackControls.togglePlay, toggleMute: volumeControls.toggleMute, toggleFullscreen: fullscreenControls.toggleFullscreen, + toggleWindowFullscreen: fullscreenControls.toggleWindowFullscreen, togglePictureInPicture: fullscreenControls.togglePictureInPicture, skipForward: skipControls.skipForward, skipBackward: skipControls.skipBackward, @@ -161,6 +162,7 @@ export function useDesktopPlayerLogic({ handlePause: playbackControls.handlePause, handleTimeUpdateEvent: playbackControls.handleTimeUpdateEvent, handleLoadedMetadata: playbackControls.handleLoadedMetadata, + handleProgressEvent: playbackControls.handleProgressEvent, handleVideoError: playbackControls.handleVideoError, handleProgressClick: progressControls.handleProgressClick, handleProgressMouseDown: progressControls.handleProgressMouseDown, @@ -170,6 +172,8 @@ export function useDesktopPlayerLogic({ handleVolumeChange: volumeControls.handleVolumeChange, handleVolumeMouseDown: volumeControls.handleVolumeMouseDown, toggleFullscreen: fullscreenControls.toggleFullscreen, + toggleNativeFullscreen: fullscreenControls.toggleNativeFullscreen, + toggleWindowFullscreen: fullscreenControls.toggleWindowFullscreen, togglePictureInPicture: fullscreenControls.togglePictureInPicture, showAirPlayMenu: fullscreenControls.showAirPlayMenu, showCastMenu: castControls.showCastMenu, diff --git a/components/player/hooks/useDesktopPlayerState.ts b/components/player/hooks/useDesktopPlayerState.ts index 0d6050e..6a6b399 100644 --- a/components/player/hooks/useDesktopPlayerState.ts +++ b/components/player/hooks/useDesktopPlayerState.ts @@ -1,5 +1,7 @@ import { useState, useRef, useMemo } from 'react'; +export type FullscreenMode = 'none' | 'native' | 'window'; + export function useDesktopPlayerState() { const videoRef = useRef(null); const containerRef = useRef(null); @@ -22,6 +24,7 @@ export function useDesktopPlayerState() { const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); + const [bufferedTime, setBufferedTime] = useState(0); const [volume, setVolume] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('kvideo-volume'); @@ -36,6 +39,7 @@ export function useDesktopPlayerState() { return false; }); const [isFullscreen, setIsFullscreen] = useState(false); + const [fullscreenMode, setFullscreenMode] = useState('none'); const [showControls, setShowControls] = useState(true); const [isLoading, setIsLoading] = useState(true); const [playbackRate, setPlaybackRate] = useState(() => { @@ -82,9 +86,11 @@ export function useDesktopPlayerState() { isPlaying, currentTime, duration, + bufferedTime, volume, isMuted, isFullscreen, + fullscreenMode, showControls, isLoading, playbackRate, @@ -104,7 +110,7 @@ export function useDesktopPlayerState() { showToast, showMoreMenu }), [ - isPlaying, currentTime, duration, volume, isMuted, isFullscreen, + isPlaying, currentTime, duration, bufferedTime, volume, isMuted, isFullscreen, fullscreenMode, showControls, isLoading, playbackRate, showSpeedMenu, isPiPSupported, isAirPlaySupported, isCastAvailable, isCasting, skipForwardAmount, skipBackwardAmount, showSkipForwardIndicator, showSkipBackwardIndicator, @@ -116,9 +122,11 @@ export function useDesktopPlayerState() { setIsPlaying, setCurrentTime, setDuration, + setBufferedTime, setVolume, setIsMuted, setIsFullscreen, + setFullscreenMode, setShowControls, setIsLoading, setPlaybackRate, diff --git a/components/player/hooks/useHlsPlayer.ts b/components/player/hooks/useHlsPlayer.ts index 8d186af..9683b63 100644 --- a/components/player/hooks/useHlsPlayer.ts +++ b/components/player/hooks/useHlsPlayer.ts @@ -78,9 +78,9 @@ export function useHlsPlayer({ lowLatencyMode: false, // Buffer Settings - maxBufferLength: 60, - maxMaxBufferLength: 120, - maxBufferSize: 60 * 1000 * 1000, + maxBufferLength: 120, + maxMaxBufferLength: 240, + maxBufferSize: 120 * 1000 * 1000, maxBufferHole: 0.5, // Start with more buffer @@ -112,7 +112,7 @@ export function useHlsPlayer({ levelLoadingTimeOut: 10000, // Backbuffer - backBufferLength: 30, + backBufferLength: 90, }; // Use custom loader if ad filtering is enabled diff --git a/components/player/web-fullscreen.css b/components/player/web-fullscreen.css index d1474a1..7fe4acd 100644 --- a/components/player/web-fullscreen.css +++ b/components/player/web-fullscreen.css @@ -10,9 +10,33 @@ z-index: 2147483647 !important; background: black !important; border-radius: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + --kvideo-web-scale: 1; } -/* Ensure controls and video take up full space in web fullscreen */ +.kvideo-container.is-web-fullscreen .kvideo-web-fullscreen-stage { + position: relative !important; + width: min(calc(100vw * var(--kvideo-web-scale)), calc(100vh * var(--kvideo-web-scale) * 16 / 9)) !important; + height: min(calc(100vh * var(--kvideo-web-scale)), calc(100vw * var(--kvideo-web-scale) * 9 / 16)) !important; + max-width: 100vw !important; + max-height: 100vh !important; +} + +.kvideo-container.is-web-fullscreen .web-fullscreen-size-full { + --kvideo-web-scale: 1; +} + +.kvideo-container.is-web-fullscreen .web-fullscreen-size-large { + --kvideo-web-scale: 0.92; +} + +.kvideo-container.is-web-fullscreen .web-fullscreen-size-focused { + --kvideo-web-scale: 0.84; +} + +/* Ensure video takes up full space in web fullscreen */ .kvideo-container.is-web-fullscreen video { width: 100% !important; height: 100% !important; @@ -28,4 +52,4 @@ left: 50% !important; transform: translate(-50%, -50%) rotate(90deg) !important; border-radius: 0 !important; -} \ No newline at end of file +} diff --git a/lib/hooks/useFloatingButtonPosition.ts b/lib/hooks/useFloatingButtonPosition.ts new file mode 100644 index 0000000..e4e093b --- /dev/null +++ b/lib/hooks/useFloatingButtonPosition.ts @@ -0,0 +1,267 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { profiledKey } from '@/lib/utils/profile-storage'; + +type FloatingAnchor = 'left' | 'right'; + +interface FloatingButtonPosition { + x: number; + y: number; +} + +interface StoredFloatingPosition { + xRatio: number; + yRatio: number; +} + +interface UseFloatingButtonPositionOptions { + storageKey: string; + defaultAnchor: FloatingAnchor; + defaultYRatio?: number; + buttonSize?: number; + margin?: number; +} + +interface DragState { + active: boolean; + dragging: boolean; + pointerId: number | null; + startClientX: number; + startClientY: number; + offsetX: number; + offsetY: number; +} + +const DRAG_THRESHOLD = 8; + +const INITIAL_DRAG_STATE: DragState = { + active: false, + dragging: false, + pointerId: null, + startClientX: 0, + startClientY: 0, + offsetX: 0, + offsetY: 0, +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +export function useFloatingButtonPosition({ + storageKey, + defaultAnchor, + defaultYRatio = 0.5, + buttonSize = 56, + margin = 16, +}: UseFloatingButtonPositionOptions) { + const [position, setPosition] = useState(null); + const dragStateRef = useRef(INITIAL_DRAG_STATE); + const positionRef = useRef(null); + const suppressClickRef = useRef(false); + + const clampPosition = useCallback((x: number, y: number, width: number, height: number) => ({ + x: clamp(x, margin, Math.max(margin, width - buttonSize - margin)), + y: clamp(y, margin, Math.max(margin, height - buttonSize - margin)), + }), [buttonSize, margin]); + + const getDefaultPosition = useCallback((width: number, height: number) => { + const x = defaultAnchor === 'left' + ? margin + : Math.max(margin, width - buttonSize - margin); + const centeredY = height * defaultYRatio - buttonSize / 2; + + return clampPosition(x, centeredY, width, height); + }, [buttonSize, clampPosition, defaultAnchor, defaultYRatio, margin]); + + const persistPosition = useCallback((nextPosition: FloatingButtonPosition) => { + if (typeof window === 'undefined') return; + + const payload: StoredFloatingPosition = { + xRatio: nextPosition.x / window.innerWidth, + yRatio: nextPosition.y / window.innerHeight, + }; + + localStorage.setItem(profiledKey(storageKey), JSON.stringify(payload)); + }, [storageKey]); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const loadPosition = () => { + const width = window.innerWidth; + const height = window.innerHeight; + const fallbackPosition = getDefaultPosition(width, height); + + try { + const raw = localStorage.getItem(profiledKey(storageKey)); + if (!raw) { + positionRef.current = fallbackPosition; + setPosition(fallbackPosition); + return; + } + + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.xRatio !== 'number' || typeof parsed.yRatio !== 'number') { + positionRef.current = fallbackPosition; + setPosition(fallbackPosition); + return; + } + + const nextPosition = clampPosition( + parsed.xRatio * width, + parsed.yRatio * height, + width, + height + ); + + positionRef.current = nextPosition; + setPosition(nextPosition); + } catch { + positionRef.current = fallbackPosition; + setPosition(fallbackPosition); + } + }; + + loadPosition(); + + const handleResize = () => { + const width = window.innerWidth; + const height = window.innerHeight; + const fallbackPosition = getDefaultPosition(width, height); + const basePosition = positionRef.current || fallbackPosition; + const nextPosition = clampPosition(basePosition.x, basePosition.y, width, height); + + positionRef.current = nextPosition; + setPosition(nextPosition); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [clampPosition, getDefaultPosition, storageKey]); + + const finishDrag = useCallback(() => { + const dragState = dragStateRef.current; + const didDrag = dragState.dragging; + + if (didDrag && positionRef.current) { + persistPosition(positionRef.current); + } + + suppressClickRef.current = didDrag; + dragStateRef.current = INITIAL_DRAG_STATE; + }, [persistPosition]); + + const handlePointerMove = useCallback((event: PointerEvent) => { + const dragState = dragStateRef.current; + + if (!dragState.active || dragState.pointerId !== event.pointerId) { + return; + } + + const movedX = Math.abs(event.clientX - dragState.startClientX); + const movedY = Math.abs(event.clientY - dragState.startClientY); + + if (!dragState.dragging && (movedX > DRAG_THRESHOLD || movedY > DRAG_THRESHOLD)) { + dragState.dragging = true; + } + + if (!dragState.dragging) { + return; + } + + event.preventDefault(); + + const nextPosition = clampPosition( + event.clientX - dragState.offsetX, + event.clientY - dragState.offsetY, + window.innerWidth, + window.innerHeight + ); + + positionRef.current = nextPosition; + setPosition(nextPosition); + }, [clampPosition]); + + const handlePointerUp = useCallback((event: PointerEvent) => { + const dragState = dragStateRef.current; + + if (!dragState.active || dragState.pointerId !== event.pointerId) { + return; + } + + finishDrag(); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + }, [finishDrag, handlePointerMove]); + + useEffect(() => { + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + }; + }, [handlePointerMove, handlePointerUp]); + + const onPointerDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return; + + const target = event.currentTarget; + const rect = target.getBoundingClientRect(); + + dragStateRef.current = { + active: true, + dragging: false, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + + window.addEventListener('pointermove', handlePointerMove, { passive: false }); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); + }, [handlePointerMove, handlePointerUp]); + + const consumeSyntheticClick = useCallback((event: React.MouseEvent) => { + if (!suppressClickRef.current) return false; + + suppressClickRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return true; + }, []); + + const floatingStyle = position + ? { + left: `${position.x}px`, + top: `${position.y}px`, + right: 'auto', + bottom: 'auto', + transform: 'none', + } + : defaultAnchor === 'left' + ? { + left: `${margin}px`, + top: '50%', + right: 'auto', + bottom: 'auto', + transform: 'translateY(-50%)', + } + : { + right: `${margin}px`, + top: '50%', + left: 'auto', + bottom: 'auto', + transform: 'translateY(-50%)', + }; + + return { + floatingStyle, + onPointerDown, + consumeSyntheticClick, + }; +}