mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: upgrade player fullscreen buffering and panel controls
This commit is contained in:
+61
-3
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user