changelog

Fix some bugs; Optimize part of animation; Optimize interaction.

preface

Recently, there was a new requirement for the project, which required multiple segments of video or audio to be clipped and spliced. For example, if a video is 30 minutes long, I need to splice 5-10 minutes, 17-22 minutes and 24-29 minutes into a whole video. Cut at the front end, spliced at the back end.

A simple search on the Internet, the basic tools are the client, there is no pure web cutting. If you don’t have one, write one.

The code has been uploaded to GitHub

Welcome to Star github.com/fengma1992/…

No more nonsense, let’s see how to design.

rendering

The function block at the bottom of the picture is a clipping tool component, and the video at the top is for demonstration, as well as audio.

Features:

  • Support mouse drag input and keyboard number input two modes;
  • Support preview play specified clipping clips;
  • Left mouse input and right keyboard input linkage;
  • The highlight drag bar is automatically captured when the mouse moves.
  • Confirm automatic weight reduction when cutting;

* Note: ICONS in the project have been replaced with text

Train of thought

Overall, a data array, cropItemList, is used to store user input data. No matter mouse drag or keyboard input, cropItemList can be operated to realize data linkage on both sides. Finally, the desired clipping is output by processing the cropItemList.

The structure of cropItemList is as follows:

cropItemList: [
    {
        startTime: 0.// Start time
        endTime: 100.// End time
        startTimeArr: [hoursStr, minutesStr, secondsStr], // Hour minute second character string
        endTimeArr: [hoursStr, minutesStr, secondsStr], // Hour minute second character string
        startTimeIndicatorOffsetX: 0.// Start time in the left drag area X offset
        endTimeIndicatorOffsetX: 100.// The end time is X offset in the left drag area}]Copy the code

The first step

Since it is a multi-segment clipping, the user needs to know which time periods are clipped, which is represented by the clipping list on the right.

The list of

A list has three states:

  • No data state

  • There’s a piece of data

  • There are multiple pieces of data

v-for
cropItemList

I’m going to separate out number 1, and then I’m going tocropItemListReverse order to produce onerenderListAnd circulationrenderListthe0 -> listLength - 2article

<template v-for="(item, index) in renderList">
    <div v-if="index < listLength -1"
         :key="index"
         class="crop-time-item">. .</div>
</template>
Copy the code

The following is the final result:

Hour minute second input

This is essentially writing three input boxes, setting type=”text”(type=number with up and down arrows to the right of the input box), and then listening for input events to ensure that the input is correct and update the data. Listen for the Focus event to determine whether a piece of data needs to be actively added when the cropItemList is empty.

<div class="time-input">
    <input type="text"
       :value="renderList[listLength -1] && renderList[listLength -1].startTimeArr[0]"
       @input="startTimeChange($event, 0, 0)"
       @focus="inputFocus()"/>
    :
    <input type="text"
       :value="renderList[listLength -1] && renderList[listLength -1].startTimeArr[1]"
       @input="startTimeChange($event, 0, 1)"
       @focus="inputFocus()"/>
    :
    <input type="text"
       :value="renderList[listLength -1] && renderList[listLength -1].startTimeArr[2]"
       @input="startTimeChange($event, 0, 2)"
       @focus="inputFocus()"/>
</div>
Copy the code

Play clips

When the play button is clicked, the currently played clip is recorded through the playingItem, and then the play event is issued to the upper layer with the start time of the play. There are also pause and stop events to control media pauses and stops.

<CropTool :duration="duration"
          :playing="playing"
          :currentPlayingTime="currentTime"
          @play="playVideo"
          @pause="pauseVideo"
          @stop="stopVideo"/>
Copy the code
/** * play the selected segment * @param index */
playSelectedClip: function (index) {
    if (!this.listLength) {
        console.log('Unclipped fragment')
        return
    }
    this.playingItem = this.cropItemList[index]
    this.playingIndex = index
    this.isCropping = false
    
    this.$emit('play'.this.playingItem.startTime || 0)}Copy the code

This controls the start of playback, so how to make the media playback at the end of the clipping time automatically stop?

Listen for the media’s timeUpdate event and compare currentTime with endTime of the playingItem in real time. When this event is reached, issue a pause event to notify the media to pause.

if (currentTime >= playingItem.endTime) {
    this.pause()
}
Copy the code

At this point, the clipping list of keyboard input is basically complete, and mouse drag and drop input is introduced below.

The second step

Here is how to input with mouse click and drag.

1. Determine the mouse interaction logic

  • New cutting

    After clicking the mouse in the drag area, add a clipping data. The start time and end time are both the time of the progress bar in mouseup, and make the end time stamp move with the mouse to enter the editing state.

  • Confirm time stamp

    Edit the state. When the mouse moves, the timestamp moves according to the current position of the mouse in the progress bar. Click the mouse again to confirm the current time and stop the timestamp from moving with the mouse.

  • Change the time

    Unedited, listens for mousemove events as the mouse moves over the progress bar, highlights the current data and displays the timestamp as it approaches the start or end timestamp of any cropped data. After mouseDown, select the timestamp and start dragging and dropping to change the time data. End the change after mouseup.

2. Determine the mouse events to listen for

The mouse listens for three events in the progress bar area :mousedown, Mousemove, and Mouseup. There are many elements in the progress bar, which can be simply divided into three categories:

  • The timestamp of mouse movement
  • There is a start time stamp, end time stamp, and light blue time mask for clipping fragments
  • The progress bar itself

First, mouseDown and Mouseup listeners are bound to the progress bar itself, of course.

this.timeLineContainer.addEventListener('mousedown', e => {
        const currentCursorOffsetX = e.clientX - containerLeft
        lastMouseDownOffsetX = currentCursorOffsetX
        // Check whether the timestamp is clicked
        this.timeIndicatorCheck(currentCursorOffsetX, 'mousedown')})this.timeLineContainer.addEventListener('mouseup', e => {

    // When the mouse pointer is up, the clipping state is removed
    if (this.isCropping) {
        this.stopCropping()
        return
    }

    const currentCursorOffsetX = this.getFormattedOffsetX(e.clientX - containerLeft)
    // If the mouseDown position is inconsistent with the mouseup position, it is not considered as a click
    if (Math.abs(currentCursorOffsetX - lastMouseDownOffsetX) > 3) {
        return
    }

    // Update the current mouse pointer time
    this.currentCursorTime = currentCursorOffsetX * this.timeToPixelRatio

    // Mouse click to add clipping fragments
    if (!this.isCropping) {
        this.addNewCropItemInSlider()

        // The new position is the last bit of the array
        this.startCropping(this.cropItemList.length - 1)}})Copy the code

Mousemove this, when not editing, of course listens for the progress bar to implement the timestamp with the mouse. When I need to select the start or end timestamp to enter the edit state, I initially want to listen for the timestamp itself to select the timestamp. In reality, there was always a mouse-following timestamp in front of me as the mouse approached the start or end timestamp, and since clipping fragments could theoretically increase indefinitely, I had to listen for 2* clipping fragments of mousemove.

Based on this, we listen to mousemove only on the progress bar itself, and then compare the mouse position with the timestamp position in real time to determine if we are in that position, with a throttle-throttle of course.

this.timeLineContainer.addEventListener('mousemove', e => {
    throttle((a)= > {
        const currentCursorOffsetX = e.clientX - containerLeft
        // Mousemove range detection
        if (currentCursorOffsetX < 0 || currentCursorOffsetX > containerWidth) {
            this.isCursorIn = false
            // The mouseup state is triggered when the mouseup state reaches the boundary
            if (this.isCropping) {
                this.stopCropping()
                this.timeIndicatorCheck(currentCursorOffsetX < 0 ? 0 : containerWidth, 'mouseup')}return
        }
        else {
            this.isCursorIn = true
        }

        this.currentCursorTime = currentCursorOffsetX * this.timeToPixelRatio
        this.currentCursorOffsetX = currentCursorOffsetX
        // Timestamp detection
        this.timeIndicatorCheck(currentCursorOffsetX, 'mousemove')
        // Timestamp movement detection
        this.timeIndicatorMove(currentCursorOffsetX)
    }, 10.true)()
})
Copy the code

3. Drag and drop and timestamp follow

The first is timestamp capture. When mousemove is used, all clipped fragments are traversed to detect whether the current mouse position is close to the clipped fragment’s timestamp. When the difference between mouse position and timestamp position is less than 2, it is considered as close (2 pixels range).

    /** * check whether the mouse is close to * @param x1 * @param x2 */
    const isCursorClose = function (x1, x2) {
        return Math.abs(x1 - x2) < 2
    }
Copy the code

If the detection is true, highlight the timestamp and the corresponding segment of the timestamp, and record the current mouse hover timestamp through the cropItemHoverIndex variable.

At the same time, mouse mousedown can select the hover timestamp and drag.

Below is the timestamp detection and the timestamp drag detection code

timeIndicatorCheck (currentCursorOffsetX, mouseEvent) {
    // In the clipped state, return directly
    if (this.isCropping) {
        return
    }

    // Reset hover state
    this.startTimeIndicatorHoverIndex = - 1
    this.endTimeIndicatorHoverIndex = - 1
    this.startTimeIndicatorDraggingIndex = - 1
    this.endTimeIndicatorDraggingIndex = - 1
    this.cropItemHoverIndex = - 1

    this.cropItemList.forEach((item, index) = > {
        if (currentCursorOffsetX >= item.startTimeIndicatorOffsetX
            && currentCursorOffsetX <= item.endTimeIndicatorOffsetX) {
            this.cropItemHoverIndex = index
        }

        // By default, the end timestamp is preferred when both the beginning and the end timestamp are together
        if (isCursorClose(item.endTimeIndicatorOffsetX, currentCursorOffsetX)) {
            this.endTimeIndicatorHoverIndex = index
            // Mouse down, start clipping
            if (mouseEvent === 'mousedown') {
                this.endTimeIndicatorDraggingIndex = index
                this.currentEditingIndex = index
                this.isCropping = true}}else if (isCursorClose(item.startTimeIndicatorOffsetX, currentCursorOffsetX)) {
            this.startTimeIndicatorHoverIndex = index
            // Mouse down, start clipping
            if (mouseEvent === 'mousedown') {
                this.startTimeIndicatorDraggingIndex = index
                this.currentEditingIndex = index
                this.isCropping = true
            }
        }
    })
},

timeIndicatorMove (currentCursorOffsetX) {
    // Clipping state, follow time stamp
    if (this.isCropping) {
        const currentEditingIndex = this.currentEditingIndex
        const startTimeIndicatorDraggingIndex = this.startTimeIndicatorDraggingIndex
        const endTimeIndicatorDraggingIndex = this.endTimeIndicatorDraggingIndex
        const currentCursorTime = this.currentCursorTime

        let currentItem = this.cropItemList[currentEditingIndex]
        // Start bit timestamp of operation
        if (startTimeIndicatorDraggingIndex > - 1 && currentItem) {
            // If the timestamp has reached the cutoff bit, it returns directly
            if (currentCursorOffsetX > currentItem.endTimeIndicatorOffsetX) {
                return
            }
            currentItem.startTimeIndicatorOffsetX = currentCursorOffsetX
            currentItem.startTime = currentCursorTime
        }

        // Operation cutoff bit timestamp
        if (endTimeIndicatorDraggingIndex > - 1 && currentItem) {
            // If the timestamp has reached the start bit, it returns directly
            if (currentCursorOffsetX < currentItem.startTimeIndicatorOffsetX) {
                return
            }
            currentItem.endTimeIndicatorOffsetX = currentCursorOffsetX
            currentItem.endTime = currentCursorTime
        }
        this.updateCropItem(currentItem, currentEditingIndex)
    }
}
Copy the code

The third step

The next step, of course, is to throw the data to the back end.

Treat users as 🍠 (# sweet potato #)

When the user presses the add button, or has a Parkinson’s button, he or she can’t drag it properly, and there may be clipped fragments with identical or overlapping data. Then we have to filter out the repetition and combine the clipping with the overlap.

It’s easier to just look at the code

/** * cropItemList sort and deduplicate */cleanCropItemList () {
    letcropItemList = this.cropItemList // 1. Sort by startTime cropItemList = cropitemlist.sort (function (item1, item2) {
        return item1.startTime - item2.startTime
    })

    let tempCropItemList = []
    let startTime = cropItemList[0].startTime
    letEndTime const lastIndex = cropItemList. Length - 1 ForEach ((item, index) => {// Iterate to the last item and write to it directlyif (lastIndex === index) {
            tempCropItemList.push({
                startTime: startTime,
                endTime: endTime,
                startTimeArr: formatTime.getFormatTimeArr(startTime),
                endTimeArr: formatTime.getFormatTimeArr(endTime),
            })
            return} // currentItem fragment contains itemif (item.endTime <= endTime && item.startTime >= startTime) {
            return} // currentItem segment overlaps itemif (item.startTime <= endTime && item.endTime >= endTime) {
            endTime = item.endTime
            return} // currentItem segment has no overlap with item, adds an item to the list, updates the record parametersif(item.startTime > endTime) { tempCropItemList.push({ startTime: startTime, endTime: endTime, startTimeArr: formatTime.getFormatTimeArr(startTime), endTimeArr: FormatTime. GetFormatTimeArr (endTime)}) / / sign moved to the current item startTime = item. The startTime endTime = item. The endTime}})return tempCropItemList
}
Copy the code

The fourth step

Using the clipping tool: Communication between the media and the clipping tool is achieved through props and EMIT events.

<template>
    <div id="app">
        <video ref="video" src="https://pan.prprpr.me/? /dplayer/hikarunara.mp4"
        controls
        width="600px">
        </video>
        <CropTool :duration="duration"
                  :playing="playing"
                  :currentPlayingTime="currentTime"
                  @play="playVideo"
                  @pause="pauseVideo"
                  @stop="stopVideo"/>
    </div>
</template>

<script>
    import CropTool from './components/CropTool.vue'
    
    export default {
        name: 'app'.components: {
            CropTool,
        },
        data () {
            return {
                duration: 0.playing: false.currentTime: 0,
            }
        },
        mounted () {
            const videoElement = this.$refs.video
            videoElement.ondurationchange = (a)= > {
                this.duration = videoElement.duration
            }
            videoElement.onplaying = (a)= > {
                this.playing = true
            }
            videoElement.onpause = (a)= > {
                this.playing = false
            }
            videoElement.ontimeupdate = (a)= > {
                this.currentTime = videoElement.currentTime
            }
        },
        methods: {
            seekVideo (seekTime) {
                this.$refs.video.currentTime = seekTime
            },
            playVideo (time) {
                this.seekVideo(time)
                this.$refs.video.play()
            },
            pauseVideo () {
                this.$refs.video.pause()
            },
            stopVideo () {
                this.$refs.video.pause()
                this.$refs.video.currentTime = 0}},}</script>
Copy the code

conclusion

Writing a blog is much harder than writing code. It feels like a mess to finish this blog.

A few little details

Height animation of list additions and deletions

There is a requirement for the UI to show a maximum of 10 clipping clips, and then scroll after that, and add and delete animations. I thought I’d just say max-height, but it turns out

CSS’s Transition animation only works for absolute height, which is a little tricky because the number of clipping bars changes, so the height is also changing. What do I do with absolute value…

Here, I use the data-count attribute of the TAG in the HTML to tell the CSS how many clipping I have, and then let the CSS set the list height based on the data-count.


<! -- If there are more than 10 entries, only 10 entries will be passed. Let the list scroll -->
<div 
    class="crop-time-body"
    :data-count="listLength > 10 ? 10 : listLength -1">
</div>

Copy the code
.crop-time-body {
    overflow-y: auto;
    overflow-x: hidden;
    transition: height .5s;

    &[data-count="0"] {
        height: 0;
    }

    &[data-count="1"] {
        height: 40px;
    }

    &[data-count="2"] { height: 80px; }... . &[data-count="10"] { height: 380px; }}Copy the code

mousemoveWhen the eventcurrentTargetThe problem

Because there are DOM event capture and bubble, and the progress bar may have other elements such as timestamp, clipping fragment, etc., the currentTarget of the Mousemove event may change, resulting in the offsetX of the mouse distance to the left of the progress bar may have problems. If the currentTarget is a progress bar, the mousemove event of the progress bar cannot be triggered for a period of time.

Instead of taking offsetX, the Mousemove event takes clientX based on the leftmost part of the page. Then subtract the two to get the pixel value of the leftmost part of the page. The code was written in adding mousemove listeners above.

Time formatting

Because the clipping tool needs to convert seconds to a string in 00:00:00 format in many places, a tool function is written: input seconds, output an Object containing dd,HH,mm,ss four keys, each key is a string of length 2. Use ES8 String. Prototype. PadStart () method.

export default function (seconds) {
    const date = new Date(seconds * 1000);
    return {
        days: String(date.getUTCDate() - 1).padStart(2.'0'),
        hours: String(date.getUTCHours()).padStart(2.'0'),
        minutes: String(date.getUTCMinutes()).padStart(2.'0'),
        seconds: String(date.getUTCSeconds()).padStart(2.'0')}; }Copy the code

Welcome more

GitHub:github.com/fengma1992/…