feat: upgrade player fullscreen buffering and panel controls

This commit is contained in:
kuekhaoyang
2026-03-26 13:51:45 +08:00
parent 286d72341e
commit 35f7f4d83a
21 changed files with 994 additions and 235 deletions
+61 -3
View File
@@ -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<PlayerViewportMode, string> = {
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<PlayerViewportMode>(() => {
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<PlayerViewportMode>(() => {
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 (
<div className="min-h-screen bg-[var(--bg-color)]">
{/* Glass Navbar */}
@@ -359,9 +392,30 @@ function PlayerContent() {
onRetry={fetchVideoDetails}
/>
) : (
<div className="grid lg:grid-cols-3 gap-6">
<div className={`grid gap-6 lg:grid-cols-3 ${playerGridClass}`}>
{/* Video Player Section */}
<div className="lg:col-span-2 space-y-6">
<div className="lg:col-span-2 xl:col-span-1 space-y-6">
<div className="hidden lg:flex items-center justify-between gap-4 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[var(--glass-bg)] p-4">
<div>
<div className="text-sm font-semibold text-[var(--text-color)]">
</div>
<div className="text-xs text-[var(--text-color-secondary)] mt-1">
{effectivePlayerViewportMode !== playerViewportMode && `,当前已自动切到${PLAYER_VIEWPORT_MODE_LABELS[effectivePlayerViewportMode]}`}
</div>
</div>
<SegmentedControl<PlayerViewportMode>
options={[
{ label: '标准', value: 'standard' },
{ label: '宽屏', value: 'wide' },
{ label: '影院', value: 'cinema' },
]}
value={playerViewportMode}
onChange={setPlayerViewportMode}
className="min-w-[240px]"
/>
</div>
<VideoPlayer
playUrl={playUrl}
videoId={videoId || undefined}
@@ -439,6 +493,10 @@ function PlayerContent() {
currentSource={currentSourceId || source || ''}
currentResolution={detectedResolution}
sourceResolutions={sourceResolutions}
sourceSectionCollapsed={isSourceSectionCollapsed}
onSourceSectionCollapseChange={setIsSourceSectionCollapsed}
episodeSectionCollapsed={isEpisodeSectionCollapsed}
onEpisodeSectionCollapseChange={setIsEpisodeSectionCollapsed}
onSourceChange={(newSource) => {
const params = new URLSearchParams();
params.set('id', String(newSource.id));
+11 -1
View File
@@ -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;
}
}
+17 -2
View File
@@ -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<HTMLElement>(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 */}
<button
onClick={() => setIsOpen(true)}
className="fixed left-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200 cursor-pointer"
onClick={(event) => {
if (consumeSyntheticClick(event)) return;
setIsOpen(true);
}}
onPointerDown={onPointerDown}
style={floatingStyle}
className="fixed z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200 cursor-pointer touch-none select-none"
aria-label="打开收藏夹"
title="点击打开收藏夹,拖动可调整位置"
>
<Icons.Heart size={24} className="text-[var(--text-color)]" />
</button>
+17 -2
View File
@@ -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<HTMLElement>(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 */}
<button
onClick={() => setIsOpen(true)}
className="fixed right-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200 cursor-pointer"
onClick={(event) => {
if (consumeSyntheticClick(event)) return;
setIsOpen(true);
}}
onPointerDown={onPointerDown}
style={floatingStyle}
className="fixed z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200 cursor-pointer touch-none select-none"
aria-label="打开观看历史"
title="点击打开观看历史,拖动可调整位置"
>
<Icons.History size={24} className="text-[var(--text-color)]" />
</button>
+73 -20
View File
@@ -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<WebFullscreenSize>(() => {
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 (
<div
ref={containerRef}
className={`kvideo-container relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.isFullscreen && fullscreenType === 'window' ? 'is-web-fullscreen' : ''
className={`kvideo-container relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.fullscreenMode === 'window' ? 'is-web-fullscreen' : ''
} ${shouldForceLandscape ? 'force-landscape' : ''}`}
onMouseMove={() => { handleMouseMove(); }}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */}
<div className={`absolute inset-0 overflow-hidden pointer-events-none ${data.isFullscreen && fullscreenType === 'window' ? 'rounded-0' : 'rounded-[var(--radius-2xl)]'
}`}>
<div className="absolute inset-0 pointer-events-auto">
<div className={stageClassName}>
{/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */}
<div className={`absolute inset-0 overflow-hidden pointer-events-none ${data.fullscreenMode === 'window' ? 'rounded-0' : 'rounded-[var(--radius-2xl)]'
}`}>
<div className="absolute inset-0 pointer-events-auto">
{/* Video Element */}
<video
ref={videoRef}
@@ -225,10 +274,11 @@ export function DesktopVideoPlayer({
onPause={handlePause}
onTimeUpdate={handleTimeUpdateEvent}
onLoadedMetadata={handleLoadedMetadata}
onProgress={handleProgressEvent}
onError={handleVideoError}
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
onClick={!isMobile ? (e) => {
onClick={!isMobile ? () => {
togglePlay();
} : undefined}
onTouchStart={isMobile ? handleTap : undefined}
@@ -257,8 +307,9 @@ export function DesktopVideoPlayer({
<DesktopOverlayWrapper
data={data}
actions={actions}
showControls={data.showControls}
isFullscreen={data.isFullscreen}
fullscreenClock={fullscreenClock}
isRotated={shouldForceLandscape}
onTogglePlay={togglePlay}
onSkipForward={logic.skipForward}
@@ -292,17 +343,19 @@ export function DesktopVideoPlayer({
onSpeedChange={logic.changePlaybackSpeed}
onSpeedMenuMouseEnter={logic.clearSpeedMenuTimeout}
onSpeedMenuMouseLeave={logic.startSpeedMenuTimeout}
webFullscreenSize={webFullscreenSize}
onCycleWebFullscreenSize={cycleWebFullscreenSize}
// Portal container
containerRef={containerRef}
/>
<DesktopControlsWrapper
src={src}
data={data}
actions={actions}
logic={logic}
refs={refs}
/>
<DesktopControlsWrapper
src={src}
data={data}
logic={logic}
refs={refs}
/>
</div>
</div>
</div>
</div>
+158 -86
View File
@@ -42,6 +42,10 @@ interface EpisodeListProps {
currentResolution?: VideoResolutionInfo | null;
// Probed resolutions for all sources (key: "source:id")
sourceResolutions?: Record<string, ResolutionInfo | null>;
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<HTMLDivElement>(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 (
<Card hover={false}>
{/* Integrated Source Selector Header */}
{showSourceSelector && (
<div className="mb-4">
<button
onClick={() => setSourceExpanded(!sourceExpanded)}
className="w-full flex items-center justify-between p-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] hover:bg-[var(--glass-hover)] transition-all duration-200"
>
<div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-2 min-w-0">
<Icons.Layers size={16} className="flex-shrink-0 text-[var(--text-color-secondary)]" />
<span className="text-sm font-medium text-[var(--text-color)] truncate">
{currentSourceInfo?.sourceName || currentSourceInfo?.source || '当前来源'}
<Icons.Layers size={18} className="text-[var(--text-color)]" />
<span className="text-base sm:text-lg font-semibold text-[var(--text-color)]">
</span>
{currentResolution && (
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${currentResolution.color} flex-shrink-0`}>
{currentResolution.label}
</span>
)}
<Badge variant="primary" className="flex-shrink-0">{sources!.length}</Badge>
<Badge variant="primary">{sources!.length}</Badge>
</div>
<Icons.ChevronDown
size={16}
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${sourceExpanded ? 'rotate-180' : ''}`}
/>
</button>
<button
onClick={() => onSourceSectionCollapseChange?.(!sourceSectionCollapsed)}
className="ml-auto p-1.5 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)] transition-all duration-200 cursor-pointer"
aria-label={sourceSectionCollapsed ? '展开源列表' : '折叠源列表'}
title={sourceSectionCollapsed ? '展开源列表' : '折叠源列表'}
>
<Icons.ChevronDown
size={16}
className={`transition-transform duration-200 ${sourceSectionCollapsed ? '-rotate-90' : 'rotate-0'}`}
/>
</button>
</div>
{/* Expanded source list */}
{sourceExpanded && (
<div className="mt-2 space-y-2">
<div className="flex justify-end">
<div className="p-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)]">
<div className="flex items-start gap-3">
<button
onClick={() => {
if (!sourceSectionCollapsed) {
setSourceExpanded(!sourceExpanded);
}
}}
className={`flex-1 min-w-0 flex items-center justify-between gap-3 text-left ${sourceSectionCollapsed ? 'cursor-default' : 'cursor-pointer'}`}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-[var(--text-color)] truncate">
{currentSourceInfo?.sourceName || currentSourceInfo?.source || '当前来源'}
</span>
{currentResolution && (
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${currentResolution.color} flex-shrink-0`}>
{currentResolution.label}
</span>
)}
</div>
{!sourceSectionCollapsed && (
<Icons.ChevronDown
size={16}
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${sourceExpanded ? 'rotate-180' : 'rotate-0'}`}
/>
)}
</button>
{!sourceSectionCollapsed && (
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation();
onClick={(event) => {
event.stopPropagation();
refreshLatencies();
}}
disabled={isLoadingLatency}
className="flex items-center gap-1.5 text-xs px-2.5 py-1"
className="flex items-center gap-1.5 text-xs px-2.5 py-1 min-h-[36px] md:px-3 md:py-1.5 md:text-sm"
>
<Icons.RefreshCw size={12} className={isLoadingLatency ? 'animate-spin' : ''} />
</Button>
</div>
)}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
<span className="truncate">
线{currentSourceInfo?.sourceName || currentSourceInfo?.source || '未知来源'}
</span>
<span className="shrink-0"> {sources!.length} </span>
</div>
</div>
{/* Expanded source list */}
{!sourceSectionCollapsed && sourceExpanded && (
<div className="mt-2 space-y-2">
{(() => {
const MAX_VISIBLE = 5;
const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
@@ -487,15 +538,14 @@ export function EpisodeList({
</div>
)}
{/* Episode List Header */}
<h3 className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
<div className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
<Icons.List size={20} className="sm:w-6 sm:h-6" />
<span></span>
{episodes && (
<Badge variant="primary">{episodes.length}</Badge>
)}
{/* Reverse order toggle button - only show when more than 1 episode */}
{showReverseToggle && (
{showReverseToggle && !episodeSectionCollapsed && (
<button
onClick={() => onToggleReverse?.(!isReversed)}
className={`
@@ -511,62 +561,84 @@ export function EpisodeList({
<Icons.ArrowUpDown size={16} />
</button>
)}
</h3>
<div
ref={listRef}
className="max-h-[400px] sm:max-h-[600px] overflow-y-auto space-y-2 pr-2"
role="radiogroup"
aria-label="剧集选择"
>
{displayEpisodes && displayEpisodes.length > 0 ? (
displayEpisodes.map((episode, displayIndex) => {
const originalIndex = getOriginalIndex(displayIndex);
const isCurrentEpisode = currentEpisode === originalIndex;
return (
<button
key={originalIndex}
ref={(el) => { buttonRefs.current[displayIndex] = el; }}
onClick={() => onEpisodeClick(episode, originalIndex)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onEpisodeClick(episode, originalIndex);
}
}}
tabIndex={0}
role="radio"
aria-checked={isCurrentEpisode}
aria-current={isCurrentEpisode ? 'true' : undefined}
aria-label={`${episode.name || `${originalIndex + 1}`}${isCurrentEpisode ? ',当前播放' : ''}`}
className={`
w-full px-3 py-2 sm:px-4 sm:py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)] cursor-pointer
${isCurrentEpisode
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
}
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm sm:text-base">
{episode.name || `${originalIndex + 1}`}
</span>
{isCurrentEpisode && (
<Icons.Play size={16} />
)}
</div>
</button>
);
})
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
<p></p>
</div>
)}
<button
onClick={() => onEpisodeSectionCollapseChange?.(!episodeSectionCollapsed)}
className="p-1.5 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)] transition-all duration-200 cursor-pointer"
aria-label={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
title={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
>
<Icons.ChevronDown
size={16}
className={`transition-transform duration-200 ${episodeSectionCollapsed ? '-rotate-90' : 'rotate-0'}`}
/>
</button>
</div>
{episodeSectionCollapsed ? (
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[var(--glass-bg)] p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-[var(--text-color-secondary)]"></span>
<span className="font-medium text-[var(--text-color)] truncate">
{currentEpisodeLabel}
</span>
</div>
</div>
) : (
<div
ref={listRef}
className="max-h-[400px] sm:max-h-[600px] overflow-y-auto space-y-2 pr-2"
role="radiogroup"
aria-label="剧集选择"
>
{displayEpisodes && displayEpisodes.length > 0 ? (
displayEpisodes.map((episode, displayIndex) => {
const originalIndex = getOriginalIndex(displayIndex);
const isCurrentEpisode = currentEpisode === originalIndex;
return (
<button
key={originalIndex}
ref={(el) => { buttonRefs.current[displayIndex] = el; }}
onClick={() => onEpisodeClick(episode, originalIndex)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onEpisodeClick(episode, originalIndex);
}
}}
tabIndex={0}
role="radio"
aria-checked={isCurrentEpisode}
aria-current={isCurrentEpisode ? 'true' : undefined}
aria-label={`${episode.name || `${originalIndex + 1}`}${isCurrentEpisode ? ',当前播放' : ''}`}
className={`
w-full px-3 py-2 sm:px-4 sm:py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)] cursor-pointer
${isCurrentEpisode
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
}
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm sm:text-base">
{episode.name || `${originalIndex + 1}`}
</span>
{isCurrentEpisode && (
<Icons.Play size={16} />
)}
</div>
</button>
);
})
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
<p></p>
</div>
)}
</div>
)}
</Card>
);
}
@@ -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<HTMLDivElement>) => void;
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => 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}
@@ -6,19 +6,20 @@ import { useDesktopPlayerLogic } from '../hooks/useDesktopPlayerLogic';
interface DesktopControlsWrapperProps {
src: string;
data: ReturnType<typeof useDesktopPlayerState>['data'];
actions: ReturnType<typeof useDesktopPlayerState>['actions'];
logic: ReturnType<typeof useDesktopPlayerLogic>;
refs: ReturnType<typeof useDesktopPlayerState>['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}
@@ -14,6 +14,8 @@ interface DesktopMoreMenuProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
webFullscreenSize: 'full' | 'large' | 'focused';
onCycleWebFullscreenSize: () => void;
containerRef: React.RefObject<HTMLDivElement | null>;
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({
</div>
</div>
<div className={`${isRotated ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-4 sm:py-2.5'} flex items-center justify-between gap-4`}>
<div className={`flex items-center gap-2 text-[var(--text-color)] ${isRotated ? 'text-[11px]' : 'text-xs sm:text-sm'}`}>
<Icons.Target size={isRotated ? 14 : 16} className="sm:w-[18px] sm:h-[18px]" />
<span></span>
</div>
<button
onClick={onCycleWebFullscreenSize}
className={`flex items-center gap-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] rounded-[var(--radius-2xl)] outline-none hover:border-[var(--accent-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] transition-all cursor-pointer whitespace-nowrap ${isRotated ? 'px-1.5 py-0.5 text-[9px]' : 'px-2 sm:px-2.5 py-1 sm:py-1.5 text-[10px] sm:text-xs'}`}
>
<span>{WEB_FULLSCREEN_SIZE_LABELS[webFullscreenSize]}</span>
<Icons.ChevronDown size={isRotated ? 10 : 12} className="text-[var(--text-color-secondary)]" />
</button>
</div>
{/* Show Mode Indicator Switch */}
<div className={`${isRotated ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-4 sm:py-2.5'} flex items-center justify-between gap-4`}>
<div className={`flex items-center gap-2 text-[var(--text-color)] ${isRotated ? 'text-[11px]' : 'text-xs sm:text-sm'}`}>
@@ -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<HTMLDivElement | null>;
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}
/>
</div>
{isFullscreen && fullscreenClock && (
<div
className={`absolute top-8 left-1/2 -translate-x-1/2 z-40 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-70'}`}
style={{ pointerEvents: 'none' }}
>
<div className="min-w-[88px] px-4 py-2 rounded-full bg-black/45 backdrop-blur-md border border-white/15 text-center shadow-[0_10px_30px_rgba(0,0,0,0.3)]">
<div className="flex items-center justify-center gap-2 text-white">
<Icons.Clock size={14} className="opacity-80" />
<span className="text-sm font-semibold tracking-[0.18em] tabular-nums">
{fullscreenClock}
</span>
</div>
</div>
</div>
)}
{/* Speed Menu (Top Right) - Moved slightly down and lower z-index */}
<div className={`absolute top-8 right-6 z-40 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`} style={{ pointerEvents: showControls ? 'auto' : 'none' }}>
<DesktopSpeedMenu
@@ -4,8 +4,9 @@ import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState';
interface DesktopOverlayWrapperProps {
data: ReturnType<typeof useDesktopPlayerState>['data'];
actions: ReturnType<typeof useDesktopPlayerState>['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<HTMLDivElement | null>;
}
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}
/>
@@ -5,6 +5,7 @@ interface DesktopProgressBarProps {
currentTime: number;
duration: number;
bufferedTime: number;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => 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' }}
>
<div
className="slider-buffer"
style={{ width: `${(bufferedTime / duration) * 100 || 0}%` }}
/>
<div
className="slider-range"
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
@@ -4,24 +4,26 @@ import { Icons } from '@/components/ui/Icon';
interface DesktopRightControlsProps {
isFullscreen: boolean;
isNativeFullscreen: boolean;
isWebFullscreen: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
isCastAvailable: boolean;
isProxied?: boolean;
onToggleFullscreen: () => 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 */}
<button
onClick={onToggleFullscreen}
onClick={onToggleWebFullscreen}
className="btn-icon"
aria-label={isFullscreen ? '退出全屏' : '全屏'}
aria-label={isWebFullscreen ? '退出网页全屏' : '网页全屏'}
title={isWebFullscreen ? '退出网页全屏 (W)' : '网页全屏 (W)'}
>
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
<Icons.Target size={20} className={isWebFullscreen ? 'text-[var(--accent-color)]' : ''} />
</button>
{/* Native Fullscreen */}
<button
onClick={onToggleNativeFullscreen}
className="btn-icon"
aria-label={isNativeFullscreen ? '退出系统全屏' : '系统全屏'}
title={isNativeFullscreen ? '退出系统全屏 (F)' : '系统全屏 (F)'}
>
{isNativeFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
</button>
</div >
);
@@ -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,
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo } from 'react';
import type { FullscreenMode } from '../useDesktopPlayerState';
interface UseFullscreenControlsProps {
containerRef: React.RefObject<HTMLDivElement | null>;
videoRef: React.RefObject<HTMLVideoElement | null>;
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;
}
@@ -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
]);
@@ -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,
@@ -1,5 +1,7 @@
import { useState, useRef, useMemo } from 'react';
export type FullscreenMode = 'none' | 'native' | 'window';
export function useDesktopPlayerState() {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(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<FullscreenMode>('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,
+4 -4
View File
@@ -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
+26 -2
View File
@@ -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;
}
}
+267
View File
@@ -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<FloatingButtonPosition | null>(null);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const positionRef = useRef<FloatingButtonPosition | null>(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<StoredFloatingPosition>;
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<HTMLElement>) => {
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<HTMLElement>) => {
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,
};
}