import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import 'videojs-playlist';
import './PlaylistButton';
import './index.scss';
// 扩展videojs类型
declare global {
interface Window {
videojs: any;
}
}
// 循环播放模式枚举
export enum LoopMode {
NONE = 'none', // 不循环
SINGLE = 'single', // 单视频循环
SEQUENCE = 'sequence', // 顺序循环整个播放列表
SHUFFLE = 'shuffle', // 随机循环整个播放列表
}
// 视频项接口
export interface VideoItem {
sources: Array<{
src: string;
type: string;
}>;
poster?: string;
title?: string;
summary?: string;
}
// 组件属性接口
interface MifcVideoProps {
videoList: VideoItem[];
autoplay?: boolean;
loop?: boolean | LoopMode; // 支持布尔值或循环模式
width?: string | number;
height?: string | number;
onVideoChange?: (index: number, isAutoPlay?: boolean) => void;
onPlay?: () => void;
onPause?: () => void;
onEnded?: () => void;
onRef?: (ref: MifcVideoRef) => void;
children?: React.ReactNode;
}
// 组件引用接口
export interface MifcVideoRef {
playByUrl: (videoUrl: string) => void;
}
const MifcVideo = forwardRef<MifcVideoRef, MifcVideoProps>(
(
{
videoList,
autoplay = false,
loop = LoopMode.SEQUENCE, // 默认顺序循环
width = '100%',
height = '100%',
onVideoChange,
onPlay,
onPause,
onEnded,
onRef,
children,
},
ref,
) => {
const videoRef = useRef<HTMLVideoElement>(null);
const playlistRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<any>(null);
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const originalPlaylistRef = useRef<VideoItem[]>([]);
const [isPlaylistVisible, setIsPlaylistVisible] = useState(false);
const isPlaylistVisibleRef = useRef(isPlaylistVisible);
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [autoplayBlocked, setAutoplayBlocked] = useState(false);
const [isPlaylistScrollable, setIsPlaylistScrollable] = useState(false);
const [isMouseNearLeftEdge, setIsMouseNearLeftEdge] = useState(false);
const [showControls, setShowControls] = useState(false);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isMobile, setIsMobile] = useState(false);
const isAutoPlayingRef = useRef(false);
const touchStartRef = useRef({ x: 0, y: 0 });
const lastCallTimeRef = useRef(0);
const [isFullscreen, setIsFullscreen] = useState(false); // 新增:跟踪全屏状态
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
playByUrl: (videoUrl: string) => {
if (playerRef.current) {
// 设置自动播放标记
isAutoPlayingRef.current = true;
// 创建临时视频项
const tempVideo: VideoItem = {
sources: [{ src: videoUrl, type: 'video/mp4' }],
title: 'AI推荐视频',
summary: '正在播放AI推荐的视频',
};
// 将临时视频添加到播放列表并播放
const currentPlaylist = (playerRef.current as any).playlist();
const videoIndex = currentPlaylist.findIndex((video: VideoItem) =>
video.sources.some((source) => source.src === videoUrl),
);
if (videoIndex >= 0) {
// 如果视频已在播放列表中,直接切换到该视频
switchVideo(videoIndex);
// onVideoChange 会通过 playlistitem 事件自动触发
} else {
// 如果视频不在播放列表中,添加到列表并播放
const newPlaylist = [...currentPlaylist, tempVideo];
(playerRef.current as any).playlist(newPlaylist, newPlaylist.length - 1);
// onVideoChange 会通过 playlistitem 事件自动触发
}
// 尝试播放
const playPromise = playerRef.current.play();
if (playPromise !== undefined) {
playPromise.catch((error: any) => {
console.warn('播放视频失败:', error);
});
}
}
},
}));
// 检测是否为移动端
const checkMobile = () => {
const userAgent = navigator.userAgent.toLowerCase();
const isMobileDevice = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const isMobileResult = isMobileDevice || isTouchDevice;
setIsMobile(isMobileResult);
return isMobileResult;
};
useEffect(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 显示控制器
const showControlsWithTimeout = useCallback(() => {
setShowControls(true);
// 清除之前的定时器
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
// 3秒后自动隐藏控制器
controlsTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 3000);
}, []);
// 隐藏控制器
const hideControls = useCallback(() => {
setShowControls(false);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
controlsTimeoutRef.current = null;
}
}, []);
// 自定义setter函数,同时更新状态和ref
const setIsPlaylistVisibleWithRef = useCallback((value: boolean) => {
setIsPlaylistVisible(value);
isPlaylistVisibleRef.current = value; // 立即更新ref
}, []);
// 显示播放列表
//showPlaylist`函数不能添加isMobile为依赖项,因为当前函数被用作useEffect的依赖项,而该useEffect用于初始化播放器。当`isMobile`状态改变时,`showPlaylist`函数会重新创建,导致`useEffect`依赖项变化,从而重新初始化播放器。在移动端,可能由于重新初始化播放器时自动播放被阻止,导致黑屏
const showPlaylist = useCallback(() => {
// 防止快速连续调用
const now = Date.now();
if (now - lastCallTimeRef.current < 300) return;
lastCallTimeRef.current = now;
const currentVisible = isPlaylistVisibleRef.current;
const mobileIsNo = checkMobile();
if (currentVisible) {
setIsPlaylistVisibleWithRef(false);
} else {
setIsPlaylistVisibleWithRef(true);
}
// 清除之前的定时器
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
if (!mobileIsNo) {
// 1.5秒后自动隐藏
hideTimeoutRef.current = setTimeout(() => {
setIsPlaylistVisibleWithRef(false);
}, 1500);
}
}, []);
// 隐藏播放列表
const hidePlaylist = useCallback((e?: React.MouseEvent | React.TouchEvent) => {
// 阻止事件冒泡到父元素,避免触发视频播放/暂停
if (e) {
e.stopPropagation();
e.preventDefault(); // 阻止默认行为
}
setIsPlaylistVisibleWithRef(false);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);
// 切换视频
const switchVideo = useCallback((index: number) => {
if (playerRef.current && playerRef.current.playlist) {
playerRef.current.playlist.currentItem(index);
// setCurrentVideoIndex 和 onVideoChange 会通过 playlistitem 事件自动触发
// 不需要在这里重复调用
// 重置隐藏定时器
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
setIsPlaylistVisibleWithRef(false);
}, 1000);
}
}, []);
// 设置循环播放模式
const setLoopMode = useCallback((mode: LoopMode) => {
if (!playerRef.current) return;
switch (mode) {
case LoopMode.NONE:
// 不循环播放
(playerRef.current as any).playlist.repeat(false);
(playerRef.current as any).playlist.autoadvance(0);
break;
case LoopMode.SINGLE:
// 单视频循环
(playerRef.current as any).playlist.repeat(false);
(playerRef.current as any).playlist.autoadvance(0);
// 设置videojs的loop属性
playerRef.current.loop(true);
break;
case LoopMode.SEQUENCE:
// 顺序循环整个播放列表
(playerRef.current as any).playlist.repeat(true);
(playerRef.current as any).playlist.autoadvance(0);
playerRef.current.loop(false);
break;
case LoopMode.SHUFFLE:
// 随机循环整个播放列表
(playerRef.current as any).playlist.repeat(false);
(playerRef.current as any).playlist.autoadvance(0);
playerRef.current.loop(false);
// 启用随机播放
(playerRef.current as any).playlist.shuffle({ rest: false });
break;
}
}, []);
// 初始化播放器
useEffect(() => {
if (!videoRef.current || !videoList.length) return;
// 保存原始播放列表
originalPlaylistRef.current = [...videoList];
// 初始化videojs播放器
const player = videojs(videoRef.current, {
controls: true,
autoplay,
loop: false, // 初始不设置循环,由循环模式控制
fluid: true,
responsive: true,
playbackRates: [0.5, 1, 1.25, 1.5, 2],
playsinline: true, // 防止移动端自动全屏
controlBar: {
children: [
'playToggle',
{
name: 'volumePanel',
inline: false,
},
'currentTimeDisplay',
'timeDivider',
'durationDisplay',
'progressControl',
'playbackRateMenuButton',
'playlistButton',
'fullscreenToggle',
],
},
});
playerRef.current = player;
// 添加播放列表组件
playerRef.current.addChild('Component', {
el: playlistRef.current,
});
// 等待播放器准备就绪后初始化播放列表
player.ready(() => {
// 使用正确的播放列表API初始化
(player as any).playlist(videoList, 0);
// 设置循环播放模式
const loopMode = typeof loop === 'boolean' ? (loop ? LoopMode.SEQUENCE : LoopMode.NONE) : loop;
setLoopMode(loopMode);
// 设置自动播放
if (autoplay) {
// 确保播放列表已加载
setTimeout(() => {
try {
(player as any).playlist.first();
const playPromise = player.play();
// 处理自动播放可能被浏览器阻止的情况
if (playPromise !== undefined) {
playPromise.catch((error: any) => {
console.warn('自动播放被阻止:', error);
setAutoplayBlocked(true);
});
}
} catch (error) {
console.warn('自动播放失败:', error);
setAutoplayBlocked(true);
}
}, 100);
}
});
// 设置事件监听器
player.on('play', () => {
setIsPlaying(true);
// showPlaylist();
onPlay?.();
});
player.on('pause', () => {
setIsPlaying(false);
onPause?.();
});
player.on('ended', () => {
setIsPlaying(false);
onEnded?.();
isAutoPlayingRef.current = false;
});
player.on('timeupdate', () => {
const currentTime = player.currentTime();
const duration = player.duration();
// 在视频即将结束时显示播放列表(剩余3秒)
if (duration && currentTime && currentTime > 0 && duration - currentTime <= 3) {
// 注释掉这里,因为会触发多次,直接播放完下一个播放就行不需要展开播放列表
// showPlaylist();
}
});
// 播放列表项变化事件
player.on('playlistitem', (event: any, data: any) => {
const currentIndex = (player as any).playlist.currentItem();
setCurrentVideoIndex(currentIndex);
onVideoChange?.(currentIndex, isAutoPlayingRef.current); // 根据自动播放标记传递参数
// // 重置自动播放标记
// isAutoPlayingRef.current = false;
});
// 播放列表按钮点击事件
player.on('playlistButtonClick', (e?: React.MouseEvent | React.TouchEvent) => {
// 彻底阻止事件传播
if (e) {
e.stopPropagation();
e.preventDefault();
}
showPlaylist();
});
// 监听全屏变化事件
player.on('fullscreenchange', () => {
setIsFullscreen(player?.isFullscreen() || false);
});
// 清理函数
return () => {
if (player) {
player.dispose();
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
if (playerRef.current) {
playerRef.current.dispose();
playerRef.current = null;
}
};
}, [videoList, autoplay, loop, showPlaylist, onVideoChange, onPlay, onPause, onEnded, setLoopMode]);
// 播放列表点击事件处理
const handlePlaylistItemClick = useCallback(
(index: number, e?: React.MouseEvent | React.TouchEvent) => {
if (e) {
e.stopPropagation(); // 阻止事件冒泡
e.preventDefault(); // 阻止默认行为
}
switchVideo(index);
isAutoPlayingRef.current = false;
},
[switchVideo],
);
// 播放列表项的触摸事件处理
const handlePlaylistItemTouchStart = useCallback((e: React.TouchEvent, index: number) => {
// 记录触摸起始位置
const touch = e.touches[0];
touchStartRef.current = {
x: touch.clientX,
y: touch.clientY,
};
e.stopPropagation();
}, []);
// 播放列表项的触结束事件处理
const handlePlaylistItemTouchEnd = useCallback(
(e: React.TouchEvent, index: number) => {
// 计算触摸移动距离
const touch = e.changedTouches[0];
const deltaX = Math.abs(touch.clientX - touchStartRef.current.x);
const deltaY = Math.abs(touch.clientY - touchStartRef.current.y);
// 只有在移动距离小于阈值时才触发点击事件
// 这样可以防止滑动时误触发点击
if (deltaX < 10 && deltaY < 10) {
handlePlaylistItemClick(index, e);
}
e.stopPropagation();
},
[handlePlaylistItemClick],
);
// 播放列表浮层的点击处理
const handlePlaylistOverlayClick = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();
}, []);
// 播放列表鼠标进入事件
const handlePlaylistMouseEnter = useCallback(() => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
// 在播放列表区域时保持控制器显示
showControlsWithTimeout();
}, [showControlsWithTimeout]);
// 播放列表鼠标离开事件
const handlePlaylistMouseLeave = useCallback(() => {
// 只有在鼠标不在左侧边缘时才启动自动隐藏定时器
if (!isMouseNearLeftEdge) {
hideTimeoutRef.current = setTimeout(() => {
setIsPlaylistVisibleWithRef(false);
}, 1000);
}
}, [isMouseNearLeftEdge]);
// 处理用户交互开始播放
const handleUserInteraction = useCallback(() => {
if (autoplayBlocked && playerRef.current) {
setAutoplayBlocked(false);
const playPromise = playerRef.current.play();
if (playPromise !== undefined) {
playPromise.catch((error: any) => {
console.warn('播放失败:', error);
});
}
}
}, [autoplayBlocked]);
// 检测播放列表内容是否可滚动
const checkPlaylistScrollable = useCallback(() => {
const playlistContent = document.querySelector('.playlist-content');
if (playlistContent) {
const isScrollable = playlistContent.scrollHeight > playlistContent.clientHeight;
setIsPlaylistScrollable(isScrollable);
}
}, []);
// 处理播放器点击事件
const handlePlayerClick = useCallback(
(event?: React.MouseEvent | React.TouchEvent) => {
// 如果是移动端且是触摸事件,不处理点击(由触摸事件处理)
if (isMobile && event && 'touches' in event) {
return;
}
// 检查触摸目标是否是播放列表按钮或相关元素
const target = event?.target as Element;
const isPlaylistButton = target?.closest('.vjs-playlist-button');
if (playerRef.current && !isPlaylistButton) {
if (isPlaying) {
playerRef.current.pause();
} else {
playerRef.current.play();
}
}
},
[isPlaying, isMobile],
);
// 处理触摸开始事件(移动端)
const handleTouchStart = useCallback(
(event: React.TouchEvent) => {
// 显示控制器
showControlsWithTimeout();
// 检查触摸目标是否是播放列表按钮或相关元素
const target = event.target as Element;
const isPlaylistButton =
target.closest('.vjs-playlist-button') ||
target.closest('.playlist-toggle-btn') ||
target.closest('.playlist-overlay');
// 在移动端触摸播放视频时关闭侧边栏,但排除播放列表相关元素
if (isMobile && !isPlaylistButton) {
hidePlaylist();
}
},
[showControlsWithTimeout, isMobile],
);
// 处理触摸结束事件(移动端)
const handleTouchEnd = useCallback(
(event: React.TouchEvent) => {
// 检查是否在播放器区域点击(非控制栏区域)
const touch = event.changedTouches[0];
const container = event.currentTarget;
const rect = container.getBoundingClientRect();
const touchY = touch.clientY - rect.top;
// 检查是否点击在控制栏区域(底部20%区域)
const isInControlBarArea = touchY > rect.height * 0.8;
// 只有在非控制栏区域点击时才触发播放/暂停
if (!isInControlBarArea) {
handlePlayerClick();
}
},
[handlePlayerClick],
);
// 处理触摸移动事件(移动端)
const handleTouchMove = useCallback((event: React.TouchEvent) => {
// 移动端不需要处理触摸移动事件
}, []);
// 处理鼠标移动到左侧边缘(仅桌面端)
const handleMouseMove = useCallback(
(event: React.MouseEvent) => {
const container = event.currentTarget;
const rect = container.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const edgeThreshold = 50; // 左侧边缘检测区域宽度
// 显示控制器
showControlsWithTimeout();
// 仅在桌面端处理左侧边缘检测
if (!isMobile) {
if (mouseX <= edgeThreshold) {
setIsMouseNearLeftEdge(true);
// 只有在播放列表未显示且不在播放列表区域内时才显示
if (!isPlaylistVisible) {
showPlaylist();
}
} else {
setIsMouseNearLeftEdge(false);
}
}
},
[isPlaylistVisible, showPlaylist, showControlsWithTimeout, isMobile],
);
// 处理鼠标离开容器(仅桌面端)
const handleMouseLeave = useCallback(
(event: React.MouseEvent) => {
// 仅在桌面端处理鼠标离开事件
if (!isMobile) {
setIsMouseNearLeftEdge(false);
hideControls();
}
},
[hideControls, isMobile],
);
// 检测播放列表可滚动状态
useEffect(() => {
if (isPlaylistVisible) {
// 延迟检测,确保DOM已更新
setTimeout(checkPlaylistScrollable, 100);
// 监听窗口大小变化
window.addEventListener('resize', checkPlaylistScrollable);
return () => window.removeEventListener('resize', checkPlaylistScrollable);
}
}, [isPlaylistVisible, videoList, checkPlaylistScrollable]);
// 控制videojs控制器的显示/隐藏
useEffect(() => {
if (playerRef.current) {
const controlBar = playerRef.current.controlBar;
if (controlBar) {
if (showControls) {
controlBar.el().classList.add('vjs-control-bar-visible');
} else {
controlBar.el().classList.remove('vjs-control-bar-visible');
}
}
}
}, [showControls]);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
}, []);
return (
<div
className={`mifc-video-container ${isFullscreen ? 'fullscreen-mode' : ''}`}
style={{ width, height }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
onContextMenu={(e) => e.preventDefault()}>
{/* 视频播放器 */}
<div
className={`video-player-wrapper ${!isMobile && isMouseNearLeftEdge ? 'edge-hover' : ''}`}
onClick={handlePlayerClick}
style={{ touchAction: 'manipulation' }}>
<video
ref={videoRef}
className="video-js vjs-default-skin"
data-setup="{}"
muted={autoplay}
playsInline
onContextMenu={(e) => e.preventDefault()}
style={{ pointerEvents: 'auto' }}
/>
{/* 自动播放被阻止时的提示 */}
{autoplayBlocked && (
<div className="autoplay-blocked-overlay" onClick={handleUserInteraction}>
<div className="autoplay-blocked-content">
<div className="autoplay-icon">▶️</div>
<div className="autoplay-text">
<h3>点击开始播放</h3>
<p>浏览器阻止了自动播放,请点击此处开始播放</p>
</div>
</div>
</div>
)}
{/* 暂停时显示的播放按钮
{!isPlaying && !autoplayBlocked && (
<div className="play-button-overlay" onClick={handlePlayerClick}>
<div className="play-button">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="40" r="40" fill="rgba(0, 0, 0, 0.7)"/>
<path d="M32 24L56 40L32 56V24Z" fill="white"/>
</svg>
</div>
</div>
)} */}
</div>
{/* 播放列表浮层 */}
<div
ref={playlistRef}
className={`playlist-overlay ${isPlaylistVisible && !isFullscreen ? 'visible' : ''} ${isFullscreen ? 'fullscreen' : ''} ${isPlaylistVisible && isFullscreen ? 'fullscreen-visible' : ''}`}
onMouseEnter={handlePlaylistMouseEnter}
onMouseLeave={handlePlaylistMouseLeave}
onClick={handlePlaylistOverlayClick} // 阻止播放列表浮层的点击事件冒泡
onTouchStart={handlePlaylistOverlayClick} // 阻止触摸事件冒泡
onTouchEnd={handlePlaylistOverlayClick} // 添加触摸结束处理
>
<div
className={`playlist-content ${isPlaylistScrollable ? 'scrollable' : ''}`}
onClick={handlePlaylistOverlayClick} // 内容区域也阻止点击
onTouchStart={handlePlaylistOverlayClick} // 内容区域也阻止触摸
>
{videoList.map((video, index) => (
<div
key={index}
className={`playlist-item ${index === currentVideoIndex ? 'active' : ''}`}
onClick={(e) => handlePlaylistItemClick(index, e)}
onTouchStart={(e) => handlePlaylistItemTouchStart(e, index)} // 添加触摸开始处理
onTouchEnd={(e) => handlePlaylistItemTouchEnd(e, index)} // 添加触摸结束处理
>
<div className="playlist-item-content">
<div className="playlist-item-title">{video.title || `视频 ${index + 1}`}</div>
<div className="playlist-item-summary">{video.summary || '点击播放视频'}</div>
</div>
</div>
))}
</div>
{/* 右侧收起按钮 */}
<div
className="playlist-toggle-btn"
onClick={hidePlaylist}
onTouchEnd={hidePlaylist} // 添加触摸事件处理
></div>
</div>
{children}
</div>
);
},
);
export default MifcVideo;
这里的children 为外部传入的组件我要怎么在vido中注册
最新发布