import * as React from 'react'; import {BarIcon} from './BarIcon'; import {ListThumbnails} from './ListThumbnails'; import {IThumbnail} from '../interfaces'; interface IProps { video: HTMLVideoElement; thumbnails: Array; trim: { start: number; end: number; }; onTrim: (start: number, end: number) => void; getClass: (text: string) => string; } interface IState { currentTime: number; thumbnailsRender: Array; trim: { start: number; end: number; }; } function getStrTime(secondsTotal: number) { let hours: number = Math.floor(secondsTotal / 3600); let mins: number = Math.floor((secondsTotal % 3600) / 60); let secs: number = Math.floor(secondsTotal % 60); let li = Math.floor((secondsTotal * 10) % 10); return ( (hours < 10 ? '0' + hours : hours) + ':' + (mins < 10 ? '0' + mins : mins) + ':' + (secs < 10 ? '0' + secs : secs) + '.' + li ); } function round(num: number) { return parseFloat(num.toFixed(3)); } const VIDEO_MIN_DURATION = 2; export class VideoTimeline extends React.Component { private controlbar: React.RefObject; private timeline: React.RefObject; private intervalTimer: number; private positionX: number; constructor(props: IProps) { super(props); this.state = { currentTime: 0, thumbnailsRender: [], trim: { start: this.props.trim.start, end: this.props.trim.end, }, }; this.controlbar = React.createRef(); this.timeline = React.createRef(); this.intervalTimer = 0; this.positionX = 0; this.handleDrag = this.handleDrag.bind(this); this.handleTimelineClick = this.handleTimelineClick.bind(this); this.handleDragOver = this.handleDragOver.bind(this); this.handleDragEnd = this.handleDragEnd.bind(this); this.tick = this.tick.bind(this); } componentDidMount() { // call tick every 100ms to update current time state // Don't use event timeupdate, because cannot set timeloop, default is 250ms this.intervalTimer = window.setInterval(this.tick, 100); // Use dragover event window to save position X, because event.clientX can not get in Firefox document.addEventListener('dragover', this.handleDragOver); this.setThumbnailsRender(); } componentDidUpdate(prevProps: IProps) { if (prevProps.thumbnails !== this.props.thumbnails) { this.setThumbnailsRender(); } if (this.props.trim !== prevProps.trim) { this.updateTrim(this.props.trim.start, this.props.trim.end); } } componentWillUnmount() { clearInterval(this.intervalTimer); document.removeEventListener('dragover', this.handleDragOver); } updateTrim(start: number, end: number) { this.setState({ trim: { start: start, end: end, }, }); } handleDrag(type: 'left' | 'right') { let time = this.getPositionInBar(this.positionX) * this.props.video.duration; if (type === 'left' && time < this.state.trim.end - VIDEO_MIN_DURATION) { this.updateTrim(time, this.state.trim.end); } if (type === 'right' && time > this.state.trim.start + VIDEO_MIN_DURATION) { this.updateTrim(this.state.trim.start, time); } } handleDragOver(e: DragEvent | React.DragEvent) { if (e.clientX) { this.positionX = e.clientX; } } // select a number of thumbnails will be rendered based on screen width // because there is not enough space for all of them setThumbnailsRender() { if (this.props.thumbnails.length === 0) { this.setState({thumbnailsRender: []}); return; } const width = this.props.thumbnails[0].width; const total = Math.floor((this.controlbar.current?.offsetWidth ?? 1) / width); const secondPerFrame = this.props.video.duration / (this.props.thumbnails.length - 1); const startPosition = width / 2 + (this.controlbar.current?.getBoundingClientRect()?.x ?? 0); const thumbnailsIndex = Array.from( Array(total).keys(), (i) => { const videoTime = this.getPositionInBar(startPosition + i * width) * this.props.video.duration; return Math.round(videoTime / secondPerFrame); }, ); let thumbnails = this.props.thumbnails.filter((_, index) => thumbnailsIndex.includes(index)); this.setState({thumbnailsRender: thumbnails}); } tick() { // updates the current time state let currentTime = this.props.video.currentTime; if (currentTime === this.state.currentTime) { return; } if (currentTime <= this.state.trim.end) { this.setState({currentTime: currentTime}); } else if (this.state.trim.end > 0) { this.setState({currentTime: this.state.trim.end}); this.props.video.pause(); } } // drag and drop left and right bar. handleDragStart(e: React.DragEvent) { // hide drag ghost image const img = new Image(); img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs='; e.dataTransfer.setDragImage(img, 0, 0); e.dataTransfer.setData('text/plain', ''); } handleDragEnd() { this.setVideoCurrentTime(this.positionX); this.props.onTrim(round(this.state.trim.start), round(this.state.trim.end)); } // returns a number between 0 and 1 // e.g. if a video is 60 seconds position 0.25 would represent a position at 15 seconds getPositionInBar(pX: number): number { const controlbar = this.controlbar.current; if (controlbar == null) { throw new Error('control bar element is not present'); } var position = (pX - controlbar.getBoundingClientRect().left) / controlbar.offsetWidth; if (position > 1) { return 1; } else if (position < 0) { return 0; } else { return Math.floor(position * 1000) / 1000; } } handleTimelineClick(e: React.MouseEvent) { let time = this.setVideoCurrentTime(e.clientX); if (time < this.state.trim.start) { this.props.onTrim(round(time), round(this.state.trim.end)); this.updateTrim(time, this.state.trim.end); } if (time > this.state.trim.end) { this.props.onTrim(round(this.state.trim.start), round(time)); this.updateTrim(this.state.trim.start, time); } } setVideoCurrentTime(pX: number) { let time = this.getPositionInBar(pX) * this.props.video.duration; this.props.video.currentTime = time; this.setState({currentTime: time}); return time; } render() { const {getClass} = this.props; const video = this.props.video; const left = video ? `${(this.state.trim.start / video.duration) * 100}%` : '0%'; const right = video ? `${(1 - this.state.trim.end / video.duration) * 100}%` : '0%'; const thumbnailWidth = Math.round(video.videoWidth / (video.videoHeight / 50)); // formula from video server let timelineWidth = 0; if (this.timeline.current != null) { const timelineStyle = window.getComputedStyle(this.timeline.current); const timelinePadding = parseInt(timelineStyle.paddingLeft, 10) + parseInt(timelineStyle.paddingRight, 10); timelineWidth = this.timeline.current.offsetWidth - timelinePadding; } // maximum total possible thumbnails const totalThumbnails = Math.floor(timelineWidth / thumbnailWidth); if (!totalThumbnails) { return (
); } return (
{getStrTime(this.state.currentTime)}
this.handleDrag('left')} onDragEnd={this.handleDragEnd} > {getStrTime(this.state.trim.start)}
this.handleDrag('right')} onDragEnd={this.handleDragEnd} > {getStrTime(this.state.trim.end)}
); } }