nicecode-v2/packages/meta/src/VideoPlayer/VideoPlayer.tsx

665 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<SetStateAction<boolean>>;
downloadVideoframe: () => void;
}
const VideoPlayer = forwardRef<VideoViewRef, VideoViewProps>((props, ref) => {
const {
url,
maxDuration = 20,
screenshotButtonAlign = {
points: ['bl', 'br'],
offset: [6, 0],
overflow: {
adjustX: true,
adjustY: true,
},
},
screenshotButtonRender = () => <div style={{ color: '#fff', width: '80px', top: 0 }}>DOM</div>,
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<number | null>(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<boolean>(false);
//回显默认框选
const isFirstFlagRef = useRef<boolean>(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<Rect| null>(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 (
<div className={classNames(`${componentName}`)} ref={containerRef}>
{url && (
<>
<FlvPlayer
playId={playSeq}
autoPlay={true}
className={classNames(`${componentName}-flv`)}
type={url.startsWith('http') ? 'mp4' : 'flv'}
url={url}
config={{
enableStashBuffer: true,
stashInitialSize: 1024 * 700,
isLive: true,
hasAudio: false,
hasVideo: true,
}}
onCreat={initPlayer}
/>
{/* //截图 */}
<div
className={classNames(`${componentName}-crop-container`)}
ref={corpContainerRef}
style={{
display: isFullscreen ? 'none' : 'block',
}}
>
{/* <div ref={corpRef}></div> */}
</div>
{showCrop && cropRect && (
<>
<div
ref={alginContainerRef}
className={classNames(`${componentName}-align`)}
style={Object.assign(
{
width: cropRect.w,
height: cropRect.h,
},
getTransforms({
translateX: cropRect.x,
translateY: cropRect.y,
})
)}
></div>
<Align
ref={alignRef}
monitorWindowResize
align={screenshotButtonAlign}
target={function () {
return alginContainerRef.current;
}}
>
{screenshotButtonRender({
model: 'IMAGE',
getCropInfo,
setShowCrop,
cropType: CROP_TYPE['CUSTOM'],
})}
</Align>
</>
)}
{/* 视频进度条 */}
{!showCrop && (
<div className={`${componentName}-opt`}>
<div>
<Button
type="text"
onClick={() => {
if (!isPlay) {
//播放中暂停
videoInsRef?.current?.play();
setShowCrop(false);
} else {
videoInsRef?.current?.pause();
}
}}
>
<Icon
styles={{
color: '#fff',
display: 'flex',
}}
color="#1890ff"
icon={!isPlay ? 'icon-shipinbofang' : 'icon-shipinzanting'}
/>
</Button>
</div>
<div
className={`${componentName}-opt-range`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Range
value={playTime}
min={0}
max={showMaxDuration}
showSlider={showSlider}
onChange={seek}
/>
<div>
{/* TODO: 删除扩展方法format */}
{formatDurationTime(playTime)}/{formatDurationTime(showMaxDuration)}
</div>
</div>
<div>
<Button
type="text"
onClick={(e) => {
e.stopPropagation();
toggleFullscreen();
}}
>
<Icon
styles={{
color: '#fff',
display: 'flex',
}}
size={18}
icon={isFullscreen ? 'icon-cancle_fullscreen' : 'icon-fullscreen'}
/>
</Button>
</div>
</div>
)}
{/* mask */}
{!!showStatus && (
<Loading status={showStatus} reload={() => reload()} />
)}
</>
)}
</div>
);
});
export default VideoPlayer;