import React, { Dispatch, ReactElement, SetStateAction, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { noop, get, addEventListenerWrapper, dataURLToBlob, nextTick, toRealNumber, getTransforms, formatDurationTime } from '@zhst/func'; import Align from 'rc-align'; import { Rect, IScreenshotButtonProp, AlignType } from '@zhst/types' import { useLatest, useUpdateEffect, useFullscreen, useUnmount } from '@zhst/hooks'; import classNames from 'classnames'; import download from 'downloadjs'; import { Button, message } from '..'; import Icon from '../iconfont'; import { Cropper, EVENT_CROP_START, EVENT_CROP_END, } from '../ImageEditor'; import FlvPlayer, { FLV_EVENT } from './components/FlvPlayer'; import Range from './components/Progress'; import Loading from './components/Loading'; import { CROP_TYPE } from '../utils/constants'; import { getShowStatus } from './videoPlayerHelper' import './index.less' const componentName = `zhst-image__video-view`; export interface VideoViewProps { /* 播放地址 */ url: string; /* 播放总时间 */ maxDuration?: number; /* 截图渲染 */ screenshotButtonAlign?: AlignType; screenshotButtonRender?: (screenshotButtonProp: IScreenshotButtonProp) => ReactElement; /* 默认截图框 */ defautlNormalizationRect?: Rect; /* 截图回调 */ onCropChange?: (showCrop: boolean, normalizationRect: null | Rect) => void; } export interface VideoViewRef { /* 当前图片模式 */ cropAble: boolean; setShowCrop: Dispatch>; downloadVideoframe: () => void; } const VideoPlayer = forwardRef((props, ref) => { const { url, maxDuration = 20, screenshotButtonAlign = { points: ['bl', 'br'], offset: [6, 0], overflow: { adjustX: true, adjustY: true, }, }, screenshotButtonRender = () =>
回调DOM
, onCropChange, defautlNormalizationRect: defaultNormalizationRect, } = props; // ========================== 播放 ========================= //实例参数 const containerRef: any = useRef(null); //容器ref const videoRef: any = useRef(null); //video 标签dom const videoInsRef: any = useRef(null); //flv 实例 const [playSeq, setPlaySeq] = useState(0); // 通过重置playid使FLV组件重新渲染 const videoRemoveListener = useRef(noop); //移除dom监听的中间函数 const loadingTimeRef = useRef(0); //最后一次加载时间 const delayLoadingTimer: any = useRef(null); //算loading的定时器 //状态参数 const [isReady, setIsReady] = useState(false); // const [isPlay, setIsPlay] = useState(false); //当前是否播放 const [isEnd, setIsEnd] = useState(false); //是否播放结束 const [isError, setIsError] = useState(false); //播放出错 const [isVideoLoadFinished, setIsVideoLoadFinish] = useState(false); //是否缓存加载完成 const [playTime, setPlayTime] = useState(0); //当前播放时间 const [isLoadingVideo, setIsLoadingVideo] = useState(true); //是否加载中 const [isDelayLoading, setIsDelayLoading] = useState(false); //显示的转圈loading 延迟0.2s显示 //设置延迟转圈圈 const latestIsLoadingVideo = useLatest(isLoadingVideo); const setIsLoadingVideoWrapper = (isLoading: boolean) => { setIsLoadingVideo((preLoading) => { if (!preLoading && isLoading) { loadingTimeRef.current = new Date().getTime(); } if (!isLoading) { loadingTimeRef.current = null; } //延迟0。2s相关 if (!isLoading) { setIsDelayLoading(false); } if (!delayLoadingTimer.current && preLoading) { delayLoadingTimer.current = setTimeout(() => { if (latestIsLoadingVideo.current) { //0.2s后才显示 setIsDelayLoading(true); } delayLoadingTimer.current = null; }, 200); } return isLoading; }); }; // 初始化loading 30s 直接显示错误 // TODO :逻辑忘记了 不应该是每次init player吗? useEffect(() => { let timer = setInterval(() => { if (loadingTimeRef.current) { if (new Date().getTime() - loadingTimeRef.current > 1000 * 30) { checkIsErr() } } }, 1000); return () => { clearInterval(timer); }; }, []); //结束的时候暂停 保证不播了 useUpdateEffect(() => { if (isEnd) { videoInsRef?.current?.pause?.(); } }, [isEnd]); // 捕捉视频播放报错 const checkIsErr = () => { setIsError(true) try { videoInsRef?.current?.destroy?.(); } catch (error) { console.error(error); } } // 初始化 const latestMaxDuration = useLatest(maxDuration); const initPlayer = useCallback((ins: any, dom: any) => { videoRef.current = dom; videoInsRef.current = ins; const maxDuration = latestMaxDuration.current || 0; //监听播放事件 let video = dom; let errorLister = (e: any) => { checkIsErr(); console.error('视频出错了', e, video.currentTime); }; let waitingListener = () => { setIsLoadingVideoWrapper(true); // console.debug('视频加载等待', e, video.currentTime); }; let playingListener = () => { setIsLoadingVideoWrapper(false); setIsError(false) // console.debug('视频从等待中播放', e, video.currentTime); }; let playLister = () => { setIsPlay(true); setIsError(false) // console.debug('提示该视频正在播放中', e, video.currentTime); }; let pauseListener = () => { setIsPlay(false); // console.debug('暂停播放', e, video.currentTime); }; let endedListner = () => { setIsEnd(true); setIsVideoLoadFinish(true); // console.debug('视频播放完了', e, video.currentTime); }; let timeupdateListner = () => { let nowTime = video.currentTime; if (nowTime >= maxDuration) { setIsEnd(true); setIsVideoLoadFinish(true); } setPlayTime(nowTime); }; // see https://github.com/bilibili/flv.js/issues/337 let windowErrorHandle = (errorEvent: any) => { try { if ( errorEvent['message'] == "Uncaught TypeError: Cannot read property 'flushStashedSamples' of null" ) { checkIsErr(); console.error('视频出错了 window监听', errorEvent); } } catch (error) { console.error(error); } }; video.addEventListener('error', errorLister); video.addEventListener('waiting', waitingListener); video.addEventListener('playing', playingListener); video.addEventListener('play', playLister); video.addEventListener('pause', pauseListener); video.addEventListener('ended', endedListner); video.addEventListener('timeupdate', timeupdateListner); window.addEventListener('error', windowErrorHandle); videoRemoveListener.current = () => { video.removeEventListener('error', errorLister); video.removeEventListener('waiting', waitingListener); video.removeEventListener('playing', playingListener); video.removeEventListener('play', playLister); video.removeEventListener('pause', pauseListener); video.removeEventListener('ended', endedListner); video.removeEventListener('timeupdate', timeupdateListner); window.removeEventListener('error', windowErrorHandle); }; videoInsRef?.current.on(FLV_EVENT.ERROR, (type: any, errDetail: any, info: any) => { checkIsErr(); console.error('videoInsRef 错误', type, errDetail, info, video.currentTime); }); let playPromise = videoInsRef?.current.play(); //先ready 遮挡会导致播放失败 setIsReady(true); playPromise .then(() => { setIsReady(true); }) .catch((...arg: any) => { try { } catch (error) {} // setIsError(true); console.error('playPromise视频出错了', arg); }); }, []); useUnmount(() => { try { videoRemoveListener.current(); } catch (e) { console.error(e); } }); const reload = async () => { if (videoInsRef.current) { let oldTime = videoInsRef.current.currentTime; videoInsRef.current.currentTime = 0; //如果修改时间不成功,则走重新加载的逻辑 if (oldTime === videoInsRef.current.currentTime) { //重置状态 setIsReady(false); setIsPlay(false); setIsLoadingVideoWrapper(false); setIsReady(false); setIsEnd(false); setIsVideoLoadFinish(false); setPlayTime(0); //清楚dom事件监听 try { videoRemoveListener.current(); } catch (error) { console.error(error); } setPlaySeq((pre) => pre + 1); return; } videoInsRef.current.play(); } setPlayTime(0); setIsEnd(false); }; const seek = (v: number) => { if (videoInsRef.current && isVideoLoadFinished) { setPlayTime(parseFloat(v as any)); videoInsRef.current.currentTime = parseFloat(v as any); } else { message.warning('待视频加载完,才可操作进度条') } }; // ========================== 视频opt bar ========================= const [isFullscreen, { toggleFullscreen }] = useFullscreen(containerRef, { pageFullscreen: true, }); const showMaxDuration = !!maxDuration ? maxDuration : toRealNumber(get(videoRef, 'current.duration', 0)); const showSlider = videoInsRef.current && isVideoLoadFinished; const showStatus: any = getShowStatus(isDelayLoading, isEnd, isError); // ========================== 截图 ========================= const corpContainerRef: any = useRef(); const cropInsRef: any = useRef(null); const [showCrop, setShowCrop] = useState(false); //回显默认框选 const isFirstFlagRef = useRef(true); useEffect(() => { const isFirst = isFirstFlagRef.current; if (!isLoadingVideo && isReady && isFirst && defaultNormalizationRect && !showStatus) { nextTick(() => { setShowCrop(true); }); } }, [isLoadingVideo, showStatus]); //定位按钮相关参数 const alginContainerRef: any = useRef(null); const alignRef: any = useRef(null); const [cropRect, setCropRect] = useState(null); useEffect(() => { showCrop ? videoInsRef?.current?.pause() : videoInsRef?.current?.play(); }, [showCrop]); useEffect(() => { let handlerCropStart: { remove: () => void; }; let handlerCropEnd: { remove: () => void; }; setCropRect(null); if (!isReady) return; if (showCrop) { handlerCropStart = addEventListenerWrapper(corpContainerRef.current, EVENT_CROP_START, () => { setCropRect(null); }); handlerCropEnd = addEventListenerWrapper(corpContainerRef.current, EVENT_CROP_END, (event: { detail: any; }) => { const data = event.detail; setCropRect({ x: data.left, y: data.top, w: data.width, h: data.height, }); alignRef?.current?.forceAlign?.(); }); let video: any = videoRef.current; //计算 limitcroppbox let scale = Math.min( video.offsetWidth / video.videoWidth, video.offsetHeight / video.videoHeight ); let finalVideoWidth = video.videoWidth * scale; let finalVideoHeight = video.videoHeight * scale; let cropBoxLimited = { width: finalVideoWidth, height: finalVideoHeight, top: (video.offsetHeight - finalVideoHeight) / 2, left: (video.offsetWidth - finalVideoWidth) / 2, }; //获取视频图片 let canvas = document.createElement('canvas'); canvas.width = video.offsetWidth; canvas.height = video.offsetHeight; canvas.style.display = 'none'; document.body.appendChild(canvas); let ctx = canvas.getContext('2d'); ctx?.drawImage( video, (video.offsetWidth - finalVideoWidth) / 2, (video.offsetHeight - finalVideoHeight) / 2, finalVideoWidth, finalVideoHeight ); let imageData = canvas.toDataURL('image/png'); canvas.parentNode?.removeChild(canvas); //回显编辑框 const isFirst = isFirstFlagRef.current; let initialCropBoxData = null; if (isFirst && defaultNormalizationRect) { initialCropBoxData = { left: defaultNormalizationRect.x * finalVideoWidth + cropBoxLimited.left, top: defaultNormalizationRect.y * finalVideoHeight + cropBoxLimited.top, width: defaultNormalizationRect.w * finalVideoWidth, height: defaultNormalizationRect.h * finalVideoHeight, }; } isFirstFlagRef.current = false; cropInsRef.current = new Cropper(corpContainerRef.current, { showMask: true, cropBoxLimited, img: imageData, initialCropBoxData, }); } return () => { handlerCropStart?.remove(); handlerCropEnd?.remove(); cropInsRef?.current?.destroy?.(); cropInsRef.current = null; }; }, [showCrop, isReady]); const latestCropRect = useLatest(cropRect); const getCropInfo = async () => { const cropRect = latestCropRect.current as any; let video: any = videoRef.current; if (!video) return let rectList = []; let extendRectList = []; let selectIndex = 0; //获取视频图片的url let scale = Math.min( video.offsetWidth / video.videoWidth, video.offsetHeight / video.videoHeight ); let finalVideoWidth = video.videoWidth * scale; let finalVideoHeight = video.videoHeight * scale; let canvas = document.createElement('canvas'); canvas.width = finalVideoWidth; canvas.height = finalVideoHeight; canvas.style.display = 'none'; document.body.appendChild(canvas); let ctx = canvas.getContext('2d') as CanvasRenderingContext2D; ctx.drawImage( video, 0, 0, finalVideoWidth, finalVideoHeight ); let base64 = canvas.toDataURL('image/jpeg'); const blobData = dataURLToBlob(base64); canvas.parentNode?.removeChild(canvas); const file = new window.File([blobData], `${new Date().getTime()}`); let newRect = { w: cropRect.w / finalVideoWidth, h: cropRect.h / finalVideoHeight, x: (cropRect.x - (video.offsetWidth - finalVideoWidth) / 2) / finalVideoWidth, y: (cropRect.y - (video.offsetHeight - finalVideoHeight) / 2) / finalVideoHeight }; rectList.push(newRect); extendRectList.push(newRect); //扩展框获取imgkey extendRectList.forEach(async (rect, index) => { extendRectList[index] = { ...rect, }; }) return { rectList, extendRectList, selectIndex, file }; }; //回调 useEffect(() => { //计算归一化crop rect let normalizationRect = null; if (showCrop && cropRect) { let video: any = videoRef.current; let scale = Math.min( video.offsetWidth / video.videoWidth, video.offsetHeight / video.videoHeight ); let finalVideoWidth = video.videoWidth * scale; let finalVideoHeight = video.videoHeight * scale; let cropBoxLimited = { width: finalVideoWidth, height: finalVideoHeight, top: (video.offsetHeight - finalVideoHeight) / 2, left: (video.offsetWidth - finalVideoWidth) / 2, }; normalizationRect = { x: (cropRect.x - cropBoxLimited.left) / cropBoxLimited.width, y: (cropRect.y - cropBoxLimited.top) / cropBoxLimited.height, w: cropRect.w / cropBoxLimited.width, h: cropRect.h / cropBoxLimited.height, }; } onCropChange?.(showCrop, normalizationRect); }, [showCrop, cropRect]); // ========================== 截帧 ========================= const downloadVideoframe = useCallback(async () => { try { videoInsRef?.current?.pause?.(); let video: any = videoRef.current; var canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d') let base64; //当视频处于还未加载出来时,截屏为黑色图片 if (video.readyState === 0) { ctx?.clearRect(0, 0, canvas.width, canvas.height); canvas.width = video.offsetWidth; canvas.height = video.offsetHeight; // @ts-ignore ctx.fillStyle = 'black'; ctx?.fillRect(0, 0, canvas.width, canvas.height); base64 = canvas.toDataURL(); } else { canvas.width = video.videoWidth; canvas.height = video.videoHeight; ctx?.drawImage(video, 0, 0, canvas.width, canvas.height); base64 = canvas.toDataURL('image/png'); } download(base64); } catch (error) { console.error(error); } }, []); // ============================== 暴露出去的方法 =============================== const latestIsReady = useLatest(isReady); const cropAble = !showStatus && isReady; useImperativeHandle(ref, () => ({ cropAble, setShowCrop: (dispatch) => { const isReady = latestIsReady.current; if (!isReady) return; setShowCrop(dispatch); }, downloadVideoframe, })); return (
{url && ( <> {/* //截图 */}
{/*
*/}
{showCrop && cropRect && ( <>
{screenshotButtonRender({ model: 'IMAGE', getCropInfo, setShowCrop, cropType: CROP_TYPE['CUSTOM'], })} )} {/* 视频进度条 */} {!showCrop && (
{ e.stopPropagation(); }} >
{/* TODO: 删除扩展方法format */} {formatDurationTime(playTime)}/{formatDurationTime(showMaxDuration)}
)} {/* mask */} {!!showStatus && ( reload()} /> )} )}
); }); export default VideoPlayer;