/** * @file video * @author fex */ import React from 'react'; import { Player, Shortcut, BigPlayButton, ControlBar, PlaybackRateMenuButton // @ts-ignore } from 'video-react'; import {padArr} from '../utils/helper'; import {Renderer, RendererProps} from '../factory'; import {resolveVariable} from '../utils/tpl-builtin'; import {filter} from '../utils/tpl'; // import css import 'video-react/dist/video-react.css'; import {BaseSchema, SchemaClassName, SchemaUrlPath} from '../Schema'; /** * 视频播放器 * 文档:https://baidu.gitee.io/amis/docs/components/video */ export interface VideoSchema extends BaseSchema { /** * 指定为视频类型 */ type: 'video'; /** * 是否自动播放 */ autoPlay?: boolean; /** * 如果显示切帧,通过此配置项可以控制每行显示多少帧 */ columnsCount?: number; /** * 设置后,可以显示切帧.点击帧的时候会将视频跳到对应时间。 * * frames: { * '01:22': 'http://domain/xxx.jpg' * } */ frames?: { [propName: string]: string; }; /** * 配置帧列表容器className */ framesClassName?: SchemaClassName; /** * 如果是实时的,请标记一下 */ isLive?: boolean; /** * 点击帧画面时是否跳转视频对应的点 * * @default true */ jumpFrame?: boolean; /** * 是否初始静音 */ muted?: boolean; /** * 配置播放器 className */ playerClassName?: SchemaClassName; /** * 视频封面地址 */ poster?: SchemaUrlPath; /** * 是否将视频和封面分开显示 */ splitPoster?: boolean; /** * 视频播放地址 */ src?: SchemaUrlPath; /** * 视频类型如: video/x-flv */ videoType?: string; /** * 视频比率 */ aspectRatio?: 'auto' | '4:3' | '16:9'; /** * 视频速率 */ rates?: Array; /** * 跳转到帧时,往前多少秒。 */ jumpBufferDuration?: number; } const str2seconds: (str: string) => number = str => str.indexOf(':') ? str .split(':') .reverse() .reduce( (seconds, value, index) => seconds + (parseInt(value, 10) || 0) * Math.pow(60, index), 0 ) : parseInt(str, 10); export interface FlvSourceProps { src?: string; type?: string; video?: any; config?: object; manager?: any; isLive?: boolean; autoPlay?: boolean; actions?: any; order?: number; setError: (error: string) => void; } // let currentPlaying: any = null; export class FlvSource extends React.Component { flvPlayer: any; loaded = false; timer: any; unsubscribe: any; componentDidMount() { let { src, video, config, manager, isLive, autoPlay, actions, setError } = this.props; this.initFlv({ video, manager, src, isLive, config, actions, setError, autoPlay }); } componentDidUpdate(prevProps: FlvSourceProps) { const props = this.props; let { autoPlay, actions, src, setError, isLive, config, video, manager } = props; if (src !== prevProps.src) { setError(''); this.flvPlayer?.destroy(); this.unsubscribe?.(); this.loaded = false; this.initFlv({ video, manager, src, isLive, config, actions, setError, autoPlay }); } } componentWillUnmount() { if (this.flvPlayer) { this.flvPlayer.destroy(); this.props.setError?.(''); } } initFlv({ video, manager, src, isLive, config, actions, setError, autoPlay }: any) { import('flv.js').then((flvjs: any) => { video = video || (manager.video && manager.video.video); let flvPlayer = flvjs.createPlayer( { type: 'flv', url: src, isLive: isLive }, config ); flvPlayer.attachMediaElement(video); this.flvPlayer = flvPlayer; this.unsubscribe = manager.subscribeToOperationStateChange( (operation: any) => { const type = operation.operation.action; if (type === 'play') { clearTimeout(this.timer); if (!this.loaded) { this.loaded = true; flvPlayer.load(); } flvPlayer.play(); } else if (type === 'pause') { flvPlayer.pause(); if (isLive) { this.timer = setTimeout(() => { actions.seek(0); flvPlayer.unload(); this.loaded = false; }, 30000); } } } ); flvPlayer.on(flvjs.Events.RECOVERED_EARLY_EOF, () => { setError('直播已经结束'); }); flvPlayer.on(flvjs.Events.ERROR, () => { setError('视频加载失败'); flvPlayer.unload(); }); if (autoPlay) { setTimeout(() => actions.play(), 200); } }); } render() { return ( ); } } export interface HlsSourceProps { src?: string; type?: string; video?: any; config?: object; manager?: any; isLive?: boolean; autoPlay?: boolean; actions?: any; order?: number; } export class HlsSource extends React.Component { hls: any; loaded = false; unsubscribe: any; componentDidMount() { let {src, video, config, manager, isLive, autoPlay, actions} = this.props; this.initHls({ video, manager, src, autoPlay, actions }); } componentWillUnmount() { if (this.hls) { this.hls.stopLoad(); this.hls.detachMedia(); } } componentDidUpdate(prevProps: FlvSourceProps) { const props = this.props; let {autoPlay, actions, src, isLive, config, video, manager} = props; if (src !== prevProps.src) { this.hls?.stopLoad(); this.hls?.detachMedia(); this.unsubscribe?.(); this.loaded = false; this.initHls({ video, manager, src, autoPlay, actions }); } } initHls({video, manager, src, autoPlay, actions}: any) { // @ts-ignore import('hls.js').then(({default: Hls}: any) => { // load hls video source base on hls.js if (Hls.isSupported()) { video = video || (manager.video && manager.video.video); let hls = (this.hls = new Hls({ autoStartLoad: false })); hls.attachMedia(video); hls.loadSource(src); this.unsubscribe = manager.subscribeToOperationStateChange( (operation: any) => { const type = operation.operation.action; if (type === 'play') { if (!this.loaded) { this.loaded = true; hls.startLoad(); } video.play(); } else if (type === 'pause') { video.pause(); hls.stopLoad(); this.loaded = false; } } ); autoPlay && setTimeout(actions.play, 200); } }); } render() { return ( ); } } export interface VideoProps extends RendererProps, Omit { columnsCount: number; } export interface VideoState { posterInfo?: any; videoState?: any; error?: string; } export default class Video extends React.Component { static defaultProps = { columnsCount: 8, isLive: false, jumpFrame: true, aspectRatio: 'auto' }; frameDom: any; cursorDom: any; player: any; times: Array; currentIndex: number; constructor(props: VideoProps) { super(props); this.state = { posterInfo: null, videoState: {} }; this.frameRef = this.frameRef.bind(this); this.cursorRef = this.cursorRef.bind(this); this.playerRef = this.playerRef.bind(this); this.onImageLoaded = this.onImageLoaded.bind(this); this.onClick = this.onClick.bind(this); this.setError = this.setError.bind(this); } onImageLoaded(e: Event) { let image: any = new Image(); image.onload = () => { this.setState({ posterInfo: { width: image.width, height: image.height } }); image = image.onload = null; }; image.src = (e.target as HTMLElement).getAttribute('src'); } frameRef(dom: any) { this.frameDom = dom; } cursorRef(dom: any) { this.cursorDom = dom; } playerRef(player: any) { this.player = player; if (!player) { return; } player.subscribeToStateChange((state: any) => { this.setState({ videoState: state }); // if (!state.paused) { // if ( // currentPlaying && // currentPlaying.video && // currentPlaying !== player // ) { // currentPlaying.pause(); // } // currentPlaying = player; // } if (!this.frameDom || !this.times) { return; } const jumpBufferDuration = this.props.jumpBufferDuration || 0; let index = 0; const times = this.times; const len = times.length; while (index < len - 1) { if ( times[index + 1] && state.currentTime < times[index + 1] - jumpBufferDuration ) { break; } index++; } if (this.currentIndex !== index) { this.moveCursorToIndex(index); } }); } moveCursorToIndex(index: number) { const {classPrefix: ns} = this.props; if (!this.frameDom || !this.cursorDom) { return; } const items = this.frameDom.querySelectorAll(`.${ns}Video-frame`); if (items && items.length && items[index]) { this.currentIndex = index; const item = items[index]; const frameRect = this.frameDom.getBoundingClientRect(); const rect = item.getBoundingClientRect(); this.cursorDom.setAttribute( 'style', `width: ${rect.width - 4}px; height: ${rect.height - 4}px; left: ${ rect.left + 2 - frameRect.left }px; top: ${rect.top + 2 - frameRect.top}px;` ); } } jumpToIndex(index: number) { if (!this.times || !this.player || !this.props.jumpFrame) { return; } const jumpBufferDuration = this.props.jumpBufferDuration || 0; const times = this.times; const player = this.player; player.seek(times[index] - jumpBufferDuration); player.play(); } onClick(e: Event) { // 避免把所在 form 给提交了。 e.preventDefault(); } setError(error?: string) { const player = this.player; this.setState({ error: error }); player?.pause(); } renderFrames() { let { frames, framesClassName, columnsCount, data, jumpFrame, classPrefix: ns, classnames: cx } = this.props; if (typeof frames === 'string' && frames[0] === '$') { frames = resolveVariable(frames, data); } if (!frames) { return null; } const items: Array = []; const times: Array = (this.times = []); Object.keys(frames).forEach(time => { times.push(str2seconds(time)); items.push({ time: time, src: frames![time] }); }); if (!items.length) { return null; } return (
{padArr(items, columnsCount).map((items, i) => { let restCount = (columnsCount as number) - items.length; let blankArray = []; while (restCount--) { blankArray.push(''); } return (
{items.map((item, key) => (
this.jumpToIndex(i * (columnsCount as number) + key) } > {item.src ? ( poster ) : null}
{item.time}
))} { /* 补充空白 */ restCount ? blankArray.map((_, index) => (
)) : null }
); })} {jumpFrame ? ( ) : null}
); } renderPlayer() { let { poster, autoPlay, muted, name, data, amisConfig, locals, isLive, minVideoDuration, videoType, playerClassName, classPrefix: ns, aspectRatio, rates, classnames: cx } = this.props; let source = this.props.src || (name && data && (data as any)[name]) || (amisConfig && amisConfig.value); const videoState = this.state.videoState; let highlight = videoState.duration && minVideoDuration && videoState.duration < minVideoDuration; let src = filter(source, data, '| raw'); let sourceNode; const error = this.state.error; if ( (src && /\.flv(?:$|\?)/.test(src) && isLive) || videoType === 'video/x-flv' ) { sourceNode = ( ); } else if ( (src && /\.m3u8(?:$|\?)/.test(src)) || videoType === 'application/x-mpegURL' ) { sourceNode = ; } else { sourceNode = ; } return (
{rates && rates.length ? ( ) : null} {sourceNode} {error ?
{error}
: null} {highlight ? (

视频时长小于 {minVideoDuration} 秒

) : null}
); } renderPosterAndPlayer() { let {poster, data, locals, minPosterDimension, classnames: cx} = this.props; const posterInfo = this.state.posterInfo || {}; let dimensionClassName = ''; if ( posterInfo && minPosterDimension && (minPosterDimension.width || minPosterDimension.height) && (minPosterDimension.width > posterInfo.width || minPosterDimension.height > posterInfo.height) ) { dimensionClassName = `Text--danger`; } return (
poster

封面{' '} {posterInfo.width || '-'} x {posterInfo.height || '-'} {dimensionClassName ? ( {' '} 封面尺寸小于{' '} {minPosterDimension.width || '-'} x{' '} {minPosterDimension.height || '-'} ) : null}

{this.renderPlayer()}
); } render() { let {splitPoster, className, classPrefix: ns, classnames: cx} = this.props; return (
{this.renderFrames()} {splitPoster ? this.renderPosterAndPlayer() : this.renderPlayer()}
); } } @Renderer({ type: 'video', name: 'video' }) export class VideoRenderer extends Video {}