superdesk/superdesk-client-core

View on GitHub
scripts/extensions/videoEditor/src/VideoTimeline/VideoTimeline.tsx

Summary

Maintainability
D
2 days
Test Coverage
import * as React from 'react';
import {BarIcon} from './BarIcon';
import {ListThumbnails} from './ListThumbnails';
import {IThumbnail} from '../interfaces';

interface IProps {
    video: HTMLVideoElement;
    thumbnails: Array<IThumbnail>;
    trim: {
        start: number;
        end: number;
    };
    onTrim: (start: number, end: number) => void;
    getClass: (text: string) => string;
}
interface IState {
    currentTime: number;
    thumbnailsRender: Array<IThumbnail>;
    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<IProps, IState> {
    private controlbar: React.RefObject<HTMLDivElement>;
    private timeline: React.RefObject<HTMLDivElement>;
    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<HTMLDivElement>) {
        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<HTMLDivElement>) {
        // 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<HTMLDivElement>) {
        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.paddingInlineStart, 10)
                + parseInt(timelineStyle.paddingInlineEnd, 10);

            timelineWidth = this.timeline.current.offsetWidth - timelinePadding;
        }
        // maximum total possible thumbnails
        const totalThumbnails = Math.floor(timelineWidth / thumbnailWidth);

        if (!totalThumbnails) {
            return (<div className={getClass('timeline-controls')} ref={this.timeline} />);
        }

        return (
            <div className={getClass('timeline-controls')} ref={this.timeline}>
                <div className={`${getClass('controlbars')}`} ref={this.controlbar} onClick={this.handleTimelineClick}>
                    <ListThumbnails
                        thumbnails={this.state.thumbnailsRender}
                        getClass={getClass}
                        thumbnailWidth={thumbnailWidth}
                        totalThumbnails={totalThumbnails}
                    />
                    <div
                        className={`${getClass('controlbars__mask')} ${getClass('controlbars__mask--left')}`}
                        style={{width: left}}
                    />
                    <div
                        className={`${getClass('controlbars__mask')} ${getClass('controlbars__mask--right')}`}
                        style={{width: right}}
                    />
                    <div
                        className={getClass('controlbars__progress-output')}
                        style={{left: video ? `${(this.state.currentTime / video.duration) * 100}%` : '0%'}}
                    >
                        <div className={getClass('controlbars__progress-output__content')}>
                            <BarIcon />
                            <div className={getClass('controlbars__progress-output__content__inner')}>
                                {getStrTime(this.state.currentTime)}
                            </div>
                        </div>
                        <div className={getClass('controlbars__progress-output__progress-line')} />
                    </div>
                    <div
                        className={`${getClass('controlbars__wrapper-out')}`}
                        style={{left: left, insetInlineEnd: right}}
                    >
                        <div
                            className={`${getClass('controlbars__wrapper')} ${getClass('controlbars__wrapper--left')}`}
                            draggable={true}
                            onDragStart={this.handleDragStart}
                            onDrag={() => this.handleDrag('left')}
                            onDragEnd={this.handleDragEnd}
                        >
                            <span className={getClass('controlbars__wrapper__content')}>
                                {getStrTime(this.state.trim.start)}
                            </span>
                        </div>
                        <div
                            className={`${getClass('controlbars__wrapper')} ${getClass('controlbars__wrapper--right')}`}
                            draggable={true}
                            onDragStart={this.handleDragStart}
                            onDrag={() => this.handleDrag('right')}
                            onDragEnd={this.handleDragEnd}
                        >
                            <span className={getClass('controlbars__wrapper__content')}>
                                {getStrTime(this.state.trim.end)}
                            </span>
                        </div>
                    </div>
                </div>
            </div>
        );
    }
}